Skip to content

Add new QTable kwarg and attribute preserve_quantity_dtype - #20247

Closed
taldcroft wants to merge 6 commits into
astropy:mainfrom
taldcroft:table-preserve-quantity-dtype
Closed

taldcroft wants to merge 6 commits into
astropy:mainfrom
taldcroft:table-preserve-quantity-dtype

Conversation

@taldcroft

@taldcroft taldcroft commented Aug 17, 2026 •

Copy link
Copy Markdown
Member

Summary

QTable converts any Column that has a unit into a Quantity, and Quantity defaults to dtype=np.inexact. An integer column with a unit is therefore always cast to float64, which silently changes values above 2**53. This hits on source identifiers, which routinely exceed that: a VOTable with datatype="long" and unit="NA" reads into Table as exact int64 and into QTable as a wrong float, with no warning (#17963).

>>> t = Table({"source_id": Column([2741100559643251862], unit="")})
>>> QTable(t)["source_id"][0]
<Quantity 2.74110056e+18>                # main: value is off by 6

>>> QTable(t, preserve_quantity_dtype=True)["source_id"][0]
<Quantity 2741100559643251862>           # this branch: exact

This PR adds an optional QTable keyword argument and attribute preserve_quantity_dtype. When it is true the Quantity is built with dtype=None, which keeps whatever dtype the input column had. The default behavior is unchanged.

Fixes #17963. This is the table side only — the io.votable behavior of treating unit="NA" or unit="" as a real unit, which is what routes these ID columns into Quantity in the first place, is left for a separate change.

This supersedes and thus closes #12505. I believe that using a proper TableAttribute that survives copy/pickle/slice/serialize is the right approach.

AI disclosure

Much of this PR is AI-generated using Claude Opus 5, but I did make substantive changes to the initial outputs. I have examined the code and fully understand the changes in table.py and the tests.

  • I certify that I am human and take responsibility for the code and interactions with reviewers.

Details

Click to expand

Add a QTable.preserve_quantity_dtype table attribute that keeps the column dtype when converting to Quantity

1. Where the dtype is lost

QTable._convert_col_for_table() is the single place where a Column with a unit becomes a Quantity:

qcol = q_cls(col.data, col.unit, copy=None, subok=True)

Quantity.__new__ has dtype=np.inexact, so int64 in gives float64 out. Nothing else in the chain is lossy — the ECSV/FITS/VOTable readers all hand QTable an exact integer column, and Table keeps it exact. Passing dtype=None instead makes Quantity keep the input dtype.

2. Implementation

All of it is in astropy/table/table.py.

The attribute. preserve_quantity_dtype = TableAttribute() on QTable, so the value lives in meta['__attributes__'] and travels with the table through slicing, copying, pickling and ECSV.

It is declared with the default None rather than default=False on purpose. MetaAttribute.__get__ copies any non-None default into meta['__attributes__'] the first time the attribute is read, so TableAttribute(default=False) would give every QTable a meta of {'__attributes__': {'preserve_quantity_dtype': False}} and write that block into every ECSV file. With the None default the getter short-circuits and meta stays empty until the attribute is explicitly set. This is the same reason Table._hidden_columns defaults to None.

The conversion.

 q_cls = Masked(Quantity) if isinstance(col, MaskedColumn) else Quantity
+# Quantity casts integer data to float unless dtype=None is supplied.
+kwargs = {"dtype": None} if self.preserve_quantity_dtype else {}
 try:
-    qcol = q_cls(col.data, col.unit, copy=None, subok=True)
+    qcol = q_cls(col.data, col.unit, copy=None, subok=True, **kwargs)

Passing the kwarg conditionally rather than dtype=np.inexact in the false branch keeps the default path from hardcoding a default that belongs to Quantity.

One reorder in Table.__init__. Table attributes were applied after init_func(), which is what builds and converts the columns. An attribute supplied as an init kwarg could therefore never affect the conversion — QTable(data, preserve_quantity_dtype=True) would have cast the columns to float and only then recorded the attribute. The self.meta assignment and the kwarg loop now run before init_func():

 self._check_names_dtype(names, dtype, n_cols)

-# Finally do the real initialization
-init_func(data, names, dtype, n_cols, copy)
-
 # Set table meta.  If copy=True then deepcopy meta otherwise use the
-# user-supplied meta directly.
+# user-supplied meta directly.  This is done before the real initialization
+# since a TableAttribute stored in meta can change how the columns get
+# converted (e.g. the QTable ``preserve_quantity_dtype`` attribute).
 if meta is not None:
     self.meta = deepcopy(meta) if copy else meta

 # Update meta with TableAttributes supplied as kwargs in Table init.
 # This takes precedence over previously-defined meta.
 if meta_table_attrs:
     for attr, value in meta_table_attrs.items():
         setattr(self, attr, value)

+# Finally do the real initialization
+init_func(data, names, dtype, n_cols, copy)

Setting meta first is what also makes the attribute work when it arrives in the input meta rather than as a kwarg, which is the ECSV read path. Precedence is unchanged: the kwarg loop still runs last and still wins over the meta value.

This reorder is safe because no init function reads or writes self.meta — the only self.meta references between __init__ and _new_from_slice are in __getstate__ and _new_from_slice itself. Verified against table, io.ascii, io.misc, io.votable, units, utils and nddata, whose MetaAttribute/meta handling would be the first thing to break.

3. Testing

TestPreserveQuantityDtype in astropy/table/tests/test_table.py, six tests using [2741100559643251862, 2733456478647137226] — real Euclid-style IDs, neither of which survives a float64 round trip. It covers the default (dtype cast to float, meta left empty), the init kwarg, setting the attribute on an existing table before adding a column, persistence through slice/copy/QTable(t)/QTable(Table(t)), kwarg precedence over a value already in meta, and a float32 column being left alone rather than upcast.

Also checked by hand:

case result
masked int column with a unit MaskedQuantity, int64, mask intact
u.mag() unit with subok=True <Magnitude [1, 2] mag>, int64
pickle round trip attribute and int64 preserved
default QTable(...) meta == {}, no __attributes__ block
copy=False with user meta qt.meta is the caller's dict, as before
user meta plus the kwarg {'x': 1, '__attributes__': {...}}, neither clobbers the other
ECSV written from a Table (plain column + unit, attribute in meta) QTable.read() gives exact int64

astropy/table, io/ascii, io/misc, io/votable, units, utils, nddata: 11775 passed, 200 skipped, 29 xfailed. docs/table and docs/io doctests: 35 passed, 6 skipped.

4. Known limitations

The attribute governs _convert_col_for_table(), so it only applies to columns that reach QTable as a Column with a unit. Two read paths bypass it, both documented in the new docs note:

  • A column written as a serialized Quantity mixin column — what QTable.write() produces for ECSV and parquet — is rebuilt by table/serialize.py through QuantityInfo._construct_from_dict(), which calls Quantity(value, unit) with the default float cast. The attribute round-trips correctly but has no effect on those columns. This is a pre-existing bug in its own right (any int64 Quantity loses its dtype through ECSV, with or without this PR) and the fix belongs in astropy.units, so it is not included here.
  • FITS drops the attribute entirely: __attributes__ is a dict and Header refuses it with "Attribute __attributes__ of type <class 'dict'> cannot be added to FITS Header - skipping". The int64 data itself is written correctly (TFORM = 'K'); only the attribute is lost.

QTable.read(..., preserve_quantity_dtype=True) is also not supported — the unified I/O layer passes unknown kwargs to the reader, not to the table class. The working idiom for now is QTable(Table.read(...), preserve_quantity_dtype=True).

5. Backwards compatibility

No behavior changes unless the new attribute is explicitly set. With it unset, _convert_col_for_table() calls Quantity with exactly the arguments it did before, QTable.preserve_quantity_dtype reads back as None, and meta is untouched. The Table.__init__ reorder is not observable — attribute precedence, meta identity under copy=False, and deepcopy semantics under copy=True are all unchanged.

A docs/changes/table/20247.feature.rst fragment is included, and docs/table/mixin_columns.rst gains a "Quantity and Integer Columns" section replacing the .. attention:: block that previously documented the float cast as unavoidable.

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

@taldcroft
taldcroft requested a review from mhvk August 17, 2026 19:09
@mhvk

mhvk commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

@taldcroft - before I review, a question about best approach: I dislike integer Quantity generally, but it seems especially bad to use it if a column is not meant to be a Quantity, which is the case here.

The most targeted approach would seem to be to simply fix votable, removing the unit if it is equal to "NA", since the intent is super clear: the unit is "Not Applicable".

Or, since that would affect round-tripping, we can still create an UnrecognizedUnit of that name, but refuse to convert to Quantity if the unit is "not applicable".

Or, one could be a bit stricter, and refuse to convert for any unrecognized unit (with maybe an option to force conversion).

Though at that point, I'd maybe tend to also not convert columns with exact dtype if the unit is dimensionless. After all, dimensionless will work correctly with quantities anyway, as that is the default assumed unit.

@taldcroft

taldcroft commented Aug 24, 2026 •

Copy link
Copy Markdown
Member Author

@mhvk - I think here we really need to avoid the temptation to guess, for instance guessing that "NA" means Not Applicable. Any kind of guessing is bound to lead to surprise for some user (esp. non-English speaker).

The most straightforward and consistent principle to apply rigorously is round tripping. Here is an example with ECSV but the same applies for FITS, VOtable, HDF5 (any format that supports rich metadata).

q0 = u.Quantity([1, 2], dtype=int, unit=u.def_unit("NA"))
q1 = u.Quantity([3, 4], dtype=int, unit="ct")
q2 = u.Quantity([5, 6], dtype=int, unit=u.dimensionless_unscaled)
qt = QTable([q0, q1, q2])
qt.info
<QTable length=2>
name dtype unit  class  
---- ----- ---- --------
col0 int64   NA Quantity
col1 int64   ct Quantity
col2 int64      Quantity
out = io.StringIO()
qt.write(out, format="ecsv")
print(out.getvalue())
# %ECSV 1.0
# ---
# datatype:
# - {name: col0, unit: NA, datatype: int64}
# - {name: col1, unit: ct, datatype: int64}
# - {name: col2, datatype: int64}
# meta: !!omap
# - __serialized_columns__:
#     col0:
#       __class__: astropy.units.quantity.Quantity
#       unit: !astropy.units.Unit {unit: NA}
#       value: !astropy.table.SerializedColumn {name: col0}
#     col1:
#       __class__: astropy.units.quantity.Quantity
#       unit: !astropy.units.Unit {unit: ct}
#       value: !astropy.table.SerializedColumn {name: col1}
#     col2:
#       __class__: astropy.units.quantity.Quantity
#       unit: !astropy.units.Unit {unit: ''}
#       value: !astropy.table.SerializedColumn {name: col2}
# schema: astropy-2.0
col0 col1 col2
1 3 5
2 4 6

The ECSV file above is an unambiguous requirements document of what the output table should look like.

qt2 = QTable.read(out.getvalue(), format="ecsv")
qt2.info

Wrong dtype! The UnitsWarning is appropriate as a heads-up that astropy is surprised. Nevertheless the output QTable should exactly match what the ECSV file specified.

WARNING: UnitsWarning: 'NA' did not parse as unit: At col 0, NA is not a valid unit. Did you mean nA, nBa, nPa or na? If this is meant to be a custom unit, define it with 'u.def_unit'. To have it recognized inside a file reader or other code, enable it with 'u.add_enabled_units'. For details, see https://docs.astropy.org/en/latest/units/combining_and_defining.html [astropy.units.core]
<QTable length=2>
name  dtype  unit  class  
---- ------- ---- --------
col0 float64   NA Quantity
col1 float64   ct Quantity
col2 float64      Quantity

@taldcroft

Copy link
Copy Markdown
Member Author

The most targeted approach would seem to be to simply fix votable, removing the unit if it is equal to "NA", since the intent is super clear: the unit is "Not Applicable".

If the VOtable standard formally defines "NA" as the unit being "not applicable", then dropping the unit upstream in votable.connect might be appropriate. But that is out of scope, and does not change the need for keeping the int dtype of other valid (and quite common) units like ct (count). Again, if the VOtable file says the dtype is int, the output should reflect that.

@taldcroft

Copy link
Copy Markdown
Member Author

Or, one could be a bit stricter, and refuse to convert for any unrecognized unit (with maybe an option to force conversion).

Since astropy provides a convenient way to create quantities with unrecognized units, we should support round-tripping them by default. The existing warning seems sufficient to me.

@taldcroft

Copy link
Copy Markdown
Member Author

At a more fundamental level, I find this behavior inconsistent and surprising:

>>> c1 = Column([1, 2], dtype=int, unit="ct")
>>> c2 = u.Quantity([1, 2], dtype=int, unit="ct")
>>> qt2 = QTable([c1, c2])
>>> qt2.info
<QTable length=2>
name  dtype  unit  class  
---- ------- ---- --------
col0 float64   ct Quantity
col1   int64   ct Quantity

This would almost argue for making preserve_quantity_dtype=True be the default, but of course this might be too much breakage.

@mhvk

mhvk commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

OK, agreed that really there are two separate concerns, one generally about integer dtype with presumably mostly a unit where that makes sense (like your ct), and one about units that are explicitly set to mean not-applicable.

Indeed, I was getting convinced that perhaps the default should be to keep dtype consistent, until I tried,

q = u.Quantity(np.arange(10), "ct", dtype=int)
q /= 10*u.s
# TypeError: Arguments cannot be cast safely to inplace output with dtype=int64

while with auto-conversion to float it succeeds,

q = u.Quantity(np.arange(10), "ct")
q /= 10*u.s

which brings back my confidence that quantities should really be float by default. (Of course, out-of-place is always OK)

Anyway, in the absence of a clear best way, your option of giving choice does seem best. I'll try to have a look at the actual PR later.

@taldcroft

Copy link
Copy Markdown
Member Author

Numpy has the same behavior, so users really should not be surprised. Indeed, in-place auto-conversion would be the real surprise:

In [1]: x = np.array([1, 2, 3])

In [2]: x /= 5.3
---------------------------------------------------------------------------
UFuncTypeError                            Traceback (most recent call last)
Cell In[2], line 1
----> 1 x /= 5.3

UFuncTypeError: Cannot cast ufunc 'divide' output from dtype('float64') to dtype('int64') with casting rule 'same_kind'

And Quantity does the right casting to float when expected:

In [4]: q = u.Quantity([1, 2], dtype=int)

In [5]: q / (5 * u.m)
Out[5]: <Quantity [0.2, 0.4] 1 / m>

@taldcroft

taldcroft commented Aug 24, 2026 •

Copy link
Copy Markdown
Member Author

BTW, I'm not saying that u.Quantity([1, 2], unit='m') should give an int dtype because that would be a huge API break at this point. Your example hinted at changing Quantity itself?

I was only talking at the QTable level with making preserve_quantity_dtype=True as the default. We'd need to consider this carefully because it would certainly change the dtype for some real world data (but arguably for the better).

@mhvk

mhvk commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Yes, maybe we can think about whether there would be some path towards keeping read-in dtypes as they are. This might be more inside the QTable.read and QTable(table) interfaces (should really look at your PR before commenting further, but several meetings to go first...).

@pllim

This comment was marked as resolved.

@taldcroft

Copy link
Copy Markdown
Member Author

Does this supersede #12505 ?

It will if it is merged. But honestly #12505 is very unlikely to get merged at this point.

@taldcroft

Copy link
Copy Markdown
Member Author

@mhvk - the actual code changes are quite simple... 2 new lines, 1 changed line and 1 moved line (thanks to TableAttribute!) .Of course there is everything else that goes with it, but I think the conceptual discussion is the hard part here.

@mhvk mhvk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now had a look. I think overall this is good, but wonder a bit about the API, and have some suggestions for the docs.

Comment thread astropy/table/tests/test_table.py Outdated
def get_table(self):
return Table(
[
Column(self.VALS, name="a", unit="m"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stupid little thing, but maybe use "ct" as a unit here?

Comment thread docs/table/mixin_columns.rst Outdated
============================

When a column of ``int`` ``dtype`` has a defined unit and is converted to
`~astropy.units.Quantity`, its ``dtype`` is converted to `float` by default. This

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to write this a bit more as being something that generally is a good idea, but that there are exceptions. E.g.,

When a column of ``int`` ``dtype`` has a defined unit and is converted to
|Quantity|, its ``dtype`` is converted to `float` by default, as is 
generally the case for |Quantity|. This makes sense for most units, for
which discrete values would be strange, but there are exceptions, with 
common ones being VOtable or FITS data tables that have integer count 
data or source IDs with, arguably illogically, defined units.

Comment thread docs/table/mixin_columns.rst Outdated
situation is common when reading VOtable or FITS data tables that have integer count
data or source IDs with defined units.

You can change this behavior and preserve the original ``dtype`` by providing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be good to give also a work-around that does not treat all columns the same way. E.g.,

A work-around is to read the table as a |Table| first and remove unwanted units
before converting to a |QTable|.

Alternatively, you can change the default behavior to be to preserve ...

Comment thread astropy/table/table.py Outdated
# Quantity subclasses identified in the unit (such as u.mag()).
q_cls = Masked(Quantity) if isinstance(col, MaskedColumn) else Quantity
# Quantity casts integer data to float unless dtype=None is supplied.
kwargs = {"dtype": None} if self.preserve_quantity_dtype else {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind the API too much, but I wonder if it would make sense instead to define a table attribute [default_]quantity_dtype=np.inexact which can be set to None, i.e., which exactly does what the dtype argument of Quantity itself does.

One advantage is that here it can just get passed on directly, another perhaps that the docstring can just refer to Quantity directly for details.

It might allow for expansion, e.g., allowing it to be a dict of column names or even a callable (though the API of that would probably be that it replaces q_cls...)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did think about this and consider your API before going to what is in this PR. I'll start with a practical problem, which is that np.inexact is an object that cannot currently be serialized to YAML by astropy.

RepresenterError: ('cannot represent an object', <class 'numpy.inexact'>)

Even if it worked, my preference is to keep this simple and readable. Setting default_quantity_dtype=None to change the behavior is confusing because None is commonly the default already. If I looked at that code I wouldn't know what it was doing. It wasn't until I started working on this PR that I learned about the Quantity dtype default.

I can't think of any compelling use case for the allowed flexibility to set the Quantity dtype default to something other than None. E.g. I don't think QTable(..., default_quantity_type=np.float32) is something users would ever do at the Table level. Going further with an idea of allowing it to be a dict becomes complicated and messy, e.g. you need to manage this attribute if table columns are renamed, etc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm certainly open to better ideas for the name. preserve_quantity_dtype is a little misleading because the quantity dtype is already preserved if you pass in a Quantity. In reality the action here is preserving the dtype of integer-type Column objects with units that are converted to Quantity. I don't know how to say that succinctly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, makes sense. The use of np.inexact was a bit of a hack in Quantity.__new__, and setting it to None is indeed rather confusing.

For another name, is quantity_preserves_dtypebetter? Or maybe revert logic and havequantity_prefers_float=True(i.e.,Falseto turn off)? Ordtypes_from_input`?

@taldcroft

Copy link
Copy Markdown
Member Author

@mhvk - will do on the other comments, thanks.

@taldcroft

taldcroft commented Aug 25, 2026 •

Copy link
Copy Markdown
Member Author

@mhvk - Here is a completely different idea for consideration. It seems cleaner to me and also brings a new possibility for Quantity.

Add a new configuration variable for units named (e.g.) quantity_convert_int_to_float. This would be string-valued with allowed values:

  • always - always convert to float
  • default - convert but allow context-specific override
  • never - never convert to float

With this you could do:

with conf.set_temp("quantity_convert_int_to_float", "never"):
    q = [1, 2] * u.ct  # int!

Then QTable init would do something like if conf.quantity_convert_int_to_float != "default" and set the config temporarily to "never" when calling the Quantity constructor. That means the default behavior for adding an int Column to a QTable will change. After staring at the docs this seems like not such a huge impact.

With that default change, I think that no changes would be required to any of the I/O layers like ECSV or FITS, but I haven't thought it through entirely.

The change in this PR will require adding the new argument to QTable.read() which is not so nice.

@mhvk

mhvk commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Yes, I think giving the user broader choice of what Quantity does is reasonable. (Since it could potentially affect science, it may be better implemented as a ScienceState, though that also feels weird; anyway, ignore that for the moment, let's assume there is some context manager that can be used.)

For the present PR, I think you meant that in the "default" case, you allow conversion, but if it is "never" or "always", you just follow what the user asked (i.e., the logic should read a bit different from what you had, if conf.quantity_convert_int_to_float == "default", temporarily set to "never"). So, the default behaviour would become like what passing in dtype=None would do. I do think that is reasonable, especially if this way the user can get the old behaviour back.

@taldcroft

Copy link
Copy Markdown
Member Author

Closed in favor of #20273

@taldcroft taldcroft closed this Aug 26, 2026
@taldcroft
taldcroft deleted the table-preserve-quantity-dtype branch August 26, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: QTable converts int columns to floats

3 participants