Table Reference¶
FITS tables are columnar catalogs. Read them as Arrow (table.read), as a
column → tensor map (table.read_torch), or as Polars (table.read_polars).
Arrow is the contract, not the native representation. The C++ engine
exports each column as a Python-owned read-only buffer without constructing a
Python tensor or importing Python torch. Clean numeric or
fixed-width byte columns go straight into pa.Array.from_buffers. See
Architecture for the boundary and what is
still to move.
| Destination | Call | Returns |
|---|---|---|
| Arrow table | table.read / table.read_arrow |
pyarrow.Table |
| Column → tensor map | table.read_torch |
dict[str, torch.Tensor] (VLA columns use list/tuple values) |
| Astropy Table | table.read_astropy |
astropy.table.Table |
| Polars | table.read_polars |
FITSPolarsFrame wrapper around pl.DataFrame |
Supports where= filters, column projection, streaming, mutations, and
handoff to Astropy, Polars, DuckDB, Pandas, and PyArrow.
Every table reader and mutator raises FileNotFoundError for a missing local
file and torchfits.HDUNotFoundError (a KeyError, IndexError,
ValueError and RuntimeError) for an hdu index or EXTNAME the file does not have; see
Missing files and HDUs.
table.read()¶
Read a FITS table as a pyarrow.Table, with optional where= and column
projection. table.read_arrow is the same function under another name.
torchfits.table.read(
path,
hdu=1,
columns=None,
row_slice=None,
rows=None,
where=None,
batch_size=65536,
mmap=True,
decode_bytes=True,
encoding="ascii",
strip=True,
include_fits_metadata=False,
apply_fits_nulls=True,
backend="auto",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
path |
str |
(required) | FITS file path |
hdu |
int or str |
1 |
Table HDU index or EXTNAME |
columns |
list[str] or None |
None |
Columns to read (None = all) |
row_slice |
slice or tuple[int,int] or None |
None |
Row range |
rows |
list[int] or None |
None |
Specific row indices |
where |
str or None |
None |
SQL-like row filter (full dialect) |
batch_size |
int |
65536 |
Read batch size |
mmap |
bool |
True |
Memory-mapped reads |
decode_bytes |
bool |
True |
Decode byte-string columns |
encoding |
str |
"ascii" |
Byte decoding encoding |
strip |
bool |
True |
Strip trailing spaces on strings |
include_fits_metadata |
bool |
False |
Attach FITS column metadata on the Arrow schema |
apply_fits_nulls |
bool |
True |
Honor TNULL as nulls |
backend |
str |
"auto" |
"auto", "cpp", or "torch" |
Returns: pyarrow.Table
df = torchfits.table.read(
"catalog.fits",
hdu=1,
columns=["RA", "DEC", "MAG_G"],
where="MAG_G < 20 AND DEC > 0",
)
print(df.num_rows, df.column_names)
table.read_torch()¶
Read selected columns as torch.Tensor values
(dict[str, torch.Tensor]).
torchfits.table.read_torch(
path,
hdu=1,
columns=None,
start_row=1,
num_rows=-1,
device="cpu",
mmap="auto",
return_header=False,
where=None,
)
| Parameter | Type | Default | Description |
|---|---|---|---|
path |
str |
(required) | FITS file path |
hdu |
int or str |
1 |
Table HDU index or EXTNAME |
columns |
list[str] or None |
None |
Columns to return (None = all) |
start_row |
int |
1 |
1-based first row |
num_rows |
int |
-1 |
Row count (-1 = through end; 0 returns every column empty, with its usual dtype, as a 0-row table does) |
device |
str |
"cpu" |
"cpu", "cuda", "mps" |
mmap |
bool or str |
"auto" |
True / False / "auto" ("auto" → mmap on) |
return_header |
bool |
False |
Also return the HDU Header |
where |
str or None |
None |
Simple numeric filter (see below) |
cache_capacity controls the read cache (0 disables it) and fast_header
only affects the unified-reader fallback. The Arrow path (table.read) takes
neither. handle_cache_capacity was removed in 1.3.0, along with
clear_file_cache(handles=) and configure_cache().
Returns: dict[str, torch.Tensor] (scalar columns; VLA columns use
list/tuple values), or (dict, Header) when return_header=True.
Scalar-column shapes¶
FITS scalar columns (repeat == 1) are returned as rank-1 tensors of shape
(N,) on every access path: table.read_torch, torchfits.read,
hdul[n].data[col], hdul[n][col], TableHDURef, iter_rows, and
streaming chunks. Vector columns (repeat > 1) keep (N, repeat); packed
string columns stay (N, width) uint8 matrices.
where= on read_torch accepts only simple predicates: comparisons
(==, !=, <, <=, >, >=), BETWEEN, and AND of those. Expressions
with OR, IN, IS NULL, or NOT raise ValueError — use
table.read(..., where=...) for the full dialect. Matching rows are kept by
reading the needed columns and applying a torch mask. When combined with a
row window (start_row / num_rows), the window selects file rows first and
the predicate filters inside it — identical to table.read(row_slice=..., where=...).
cols = torchfits.table.read_torch("catalog.fits", hdu=1, columns=["RA", "DEC"])
bright = torchfits.table.read_torch(
"catalog.fits", hdu=1, columns=["MAG"], where="MAG < 20"
)
For many unfiltered column reads on one file, use
torchfits.open_table_reader(path, hdu=1) (no where= on the handle).
table.scan()¶
Streaming dataframe scanner yielding pyarrow.RecordBatch objects without
materializing the entire table.
torchfits.table.scan(
path,
hdu=1,
columns=None,
row_slice=None,
where=None,
batch_size=65536,
mmap=True,
decode_bytes=True,
encoding="ascii",
strip=True,
include_fits_metadata=False,
apply_fits_nulls=True,
backend="auto",
)
Yields: pyarrow.RecordBatch
for batch in torchfits.table.scan("survey.fits", hdu=1, batch_size=50_000):
print(batch.num_rows, batch.column_names) # pyarrow.RecordBatch
When to use
Use scan() when the table is too large to fit in memory, or when you
want to process rows in streaming fashion. With where=, the predicate is
evaluated per batch as rows stream past, so peak memory stays bounded
by batch_size, not table size. For Polars-specific streaming,
use scan_polars().
table.scan_torch()¶
Stream row chunks as dict[str, torch.Tensor]. No where= — filter with
table.scan(..., where=...) or mask batches yourself.
torchfits.table.scan_torch(
path,
hdu=1,
columns=None,
row_slice=None,
batch_size=65536,
mmap=True,
device="cpu",
non_blocking=True,
pin_memory=False,
)
Yields: dict[str, torch.Tensor]
for batch in torchfits.table.scan_torch("survey.fits", hdu=1, batch_size=10000):
print(batch.keys()) # dict[str, torch.Tensor]
table.reader()¶
Open a FITS table as a pyarrow.RecordBatchReader for streaming.
torchfits.table.reader(
path,
hdu=1,
columns=None,
row_slice=None,
where=None,
batch_size=65536,
mmap=True,
decode_bytes=True,
encoding="ascii",
strip=True,
include_fits_metadata=True,
apply_fits_nulls=True,
backend="auto",
)
Returns: pyarrow.RecordBatchReader
table.write()¶
Write a columnar dictionary as a FITS binary or ASCII table.
torchfits.table.write(path, data, *, schema=None, header=None,
overwrite=False, extname=None, table_type="binary",
quantize=None)
| Parameter | Type | Default | Description |
|---|---|---|---|
path |
str |
(required) | Output path |
data |
dict, Table, or DataFrame |
(required) | Column dictionary, Astropy Table, PyArrow Table, or DataFrame |
schema |
dict[str, dict[str, Any]] or None |
None |
Per-column FITS layout overrides: format/tform (e.g. {"bits": {"format": "4X"}}), unit/tunit, null/tnull, bscale, bzero, dim/tdim |
header |
dict or None |
None |
FITS header key-value pairs |
extname |
str or None |
None |
EXTNAME for the written table HDU (merged into header) |
overwrite |
bool |
False |
Overwrite existing file |
table_type |
str |
"binary" |
"binary" or "ascii" |
quantize |
None or str or dict |
None |
Opt-in robust TFORM=I + TSCAL/TZERO for float columns ("robust" for all floats, {"FLUX": "robust"} per column, or options {"lo_q","hi_q","keep_zero"}). Integer columns are left alone. Default keeps native float TFORM. |
schema scaling and nulls describe stored values:
- With
bscale/bzero, the column data you pass is written as the stored integers and the file's readers applyvalue = stored * TSCAL + TZERO.{"F": {"format": "I", "bscale": 0.5, "bzero": 10.0}}with data[100, 200]reads back as[60.0, 110.0]. null/tnullis written verbatim asTNULLn, which FITS compares with the stored value beforeTSCAL/TZERO. The exception is a column written fromuint16/uint32/uint64/int8data: torchfits stores it with the standardTZEROoffset, so givetnullas a value of that dtype (for example65535foruint16) and it is written as65535 - 32768. Atnulloutside the dtype's range raisesValueError.- A
tnullthat is not an integer (or an integer string), and abscale/bzerothat is not a finite number, raiseValueErrornaming the field; so do thetnull=/tscal=/tzero=arguments ofinsert_columnandreplace_column.
quantize applies to binary tables only (ValueError when table_type="ascii").
If it is set and no floating-point column qualifies, QuantizeError is raised
instead of writing those columns unpacked.
torchfits.table.write("out.fits", {"RA": ra, "DEC": dec}, overwrite=True)
torchfits.table.write(
"packed.fits",
{"ID": ids, "FLUX": flux},
quantize={"FLUX": "robust"},
overwrite=True,
)
Row filters (where=)¶
Pass where= to keep matching rows.
| Call | Dialect | How it filters |
|---|---|---|
table.read / table.scan |
Full (see operators below) | C++ mmap scan when safe (backend="auto" + mmap=True, no VLA in the projection) and PyTorch is already imported; otherwise read then Arrow filter. where= never imports PyTorch itself |
table.read_torch |
Simple only: compare / BETWEEN / AND |
Read projected columns, then torch mask. OR / IN / IS NULL / NOT raise ValueError |
Root torchfits.read() has no where= parameter.
Operators on table.read / table.scan:
| Operator | Example |
|---|---|
== / != |
where="CLASS == 'star'" |
< / > / <= / >= |
where="MAG_G < 20" |
AND / OR |
where="MAG_G < 20 AND DEC > 0" |
NOT |
where="NOT CLASS == 'star'" |
IN (...) |
where="id IN (1, 2, 3)" |
NOT IN (...) |
where="id NOT IN (4, 5)" |
BETWEEN ... AND ... |
where="MAG_G BETWEEN 15 AND 20" |
IS NULL / IS NOT NULL |
where="DEC IS NOT NULL" |
Use == for equality
The WHERE dialect is Python-expression based — single = is not an
operator; write == (also accepts && / || / ~ C-style forms).
String literals are literal
Quote strings with '...' or "...". A doubled quote is one quote
character ('O''NEIL'); a backslash is an ordinary character, so
'a\nb' is four characters, not a newline. BETWEEN takes string
bounds (NAME BETWEEN 'a' AND 'm') as well as numbers such as .5.
How null rows are filtered
With apply_fits_nulls=True (the default) a TNULL sentinel is a null
and the dialect follows SQL three-valued logic: a null carries an
unknown value, so no predicate — positive or negated — selects it.
| Predicate | A TNULL row |
|---|---|
==, !=, <, >, <=, >=, IN, NOT IN, BETWEEN, NOT BETWEEN |
not selected |
NOT (...) |
not selected |
IS NULL |
selected |
IS NOT NULL |
not selected |
Negation therefore always agrees with the dual operator:
where="NOT (A == 1)" and where="A != 1" return the same rows, and so
do NOT (A > 1) / A <= 1 and NOT (A IN (...)) / A NOT IN (...).
On float columns a NaN pixel is treated the same way: IEEE makes
NaN != v true, but the comparison is unknown, so the row is excluded,
and IS NULL selects it. A scaled column's TNULL cells read as NaN.
This holds identically on all three read backends (auto, cpp, torch)
and matches the torchfits.where reference evaluator. Use IS NULL /
IS NOT NULL to test for absence. == NULL / != NULL on a numeric or
logical column raise ValueError.
TNULLn is the stored value, compared before TSCALn/TZEROn as in
the FITS standard: on a uint16 column (TZERO = 32768) TNULL = 0
marks cells that read as 32768. Files written by torchfits 1.3.1 stored
an unsigned-column TNULL as the value instead (TNULL = 65535 on that
uint16 column). A TNULL that cannot be a stored value (outside the
stored integer range) but is a valid value of the column dtype is read
that way, so those nulls still read as nulls.
backend= on table.read / table.scan¶
| Value | Behavior |
|---|---|
"auto" (default) |
With where= and mmap=True, C++ pushdown when the header is readable, the projection has no VLA columns, and PyTorch is already imported; otherwise Arrow filter. With mmap=False, buffered read then filter. |
"cpp" |
Prefer C++ column / pushdown paths when safe |
"torch" |
Chunked path via table.scan_torch |
Table reads open a private CFITSIO handle per call. Disk cache roots and
shared metadata use the TORCHFITS_* variables in
Architecture and Core I/O.
Predicate Helpers¶
The torchfits.where module provides predicate parsing and evaluation
outside of table reads.
from torchfits.where import evaluate_where, parse_where_expression
ast = parse_where_expression("MAG_G < 20 AND DEC IS NOT NULL")
mask = evaluate_where(ast, {"MAG_G": magnitudes, "DEC": declinations})
# mask: np.ndarray[bool]
Additional public names: parse_where_literal, tokenize_where_expression,
normalize_where_syntax, where_columns_from_ast. They read quoted literals
as where= does: parse_where_literal("'O''NEIL'") is O'NEIL, and a
backslash is an ordinary character.
Table mutation¶
Rewrite helpers on an existing table HDU:
torchfits.table.append_rows(path, rows, hdu=1)
torchfits.table.insert_rows(path, rows, row=0, hdu=1)
torchfits.table.update_rows(path, rows, row_slice, hdu=1, mmap="auto")
torchfits.table.delete_rows(path, row_slice, hdu=1)
torchfits.table.insert_column(
path,
name,
values,
hdu=1,
index=None,
format=None,
unit=None,
dim=None,
tnull=None,
tscal=None,
tzero=None,
)
torchfits.table.replace_column(
path,
name,
values,
hdu=1,
format=None,
unit=None,
dim=None,
tnull=None,
tscal=None,
tzero=None,
)
torchfits.table.rename_columns(path, {"old_name": "new_name"}, hdu=1)
torchfits.table.drop_columns(path, ["col_a", "col_b"], hdu=1)
row_slice is a slice or (start, stop) tuple (0-based Python style for
update/delete). insert_rows requires keyword row= (0-based insert index).
update_rows raises ValueError, before writing anything, when an integer
value does not fit the column (for example 70000 into a uint16 column);
float values written into an integer column only warn that they are
truncated toward zero.
Without format=, insert_column / replace_column infer the TFORM from
the values. As in table.write, uint16 / uint32 / uint64 values become
I / J / K with TZERO = 2**15 / 2**31 / 2**63, and int8 becomes
B with TZERO = -128. On an ASCII table (XTENSION = 'TABLE') the inferred format is an
ASCII one (I6, I11, E25.15, A8, ...); values an ASCII table cannot
hold (booleans, vectors, complex) raise ValueError asking for format=.
Without format=, replace_column keeps the column's TFORM unless the new
values are uint16 / uint32 / uint64 / int8 and the column does not
already use that TZERO convention: then the column layout is replaced too,
exactly as insert_column would infer it, and the old TZERO / TSCAL /
TNULL are dropped. With format= and such values, a format of the same
storage code (I for uint16, ...) gets the TZERO convention; any other
integer or float format stores the values plainly when they fit its range,
and raises ValueError otherwise.
When the column keeps its TSCAL / TZERO (no tscal= / tzero= given and
the layout is not replaced as above), replace_column takes physical
values, the ones a read returns, as update_rows does: [1, 2, 3] on a
uint16 column reads back as [1, 2, 3], and on a TSCAL = 0.5,
TZERO = 10 column it is stored as (value - 10) / 0.5 (rounded half away
from zero for an integer TFORM). A value the column cannot hold (70000 for
uint16, a fraction for an unsigned column, NaN without a TNULL) raises
ValueError naming the column, before anything is written. Passing tscal=
or tzero= sets a new scaling, and the values are then the stored integers,
as with bscale / bzero in table.write.
import numpy as np
import torchfits
torchfits.table.write(
"s.fits",
{"f": np.array([100, 200], dtype=np.int16)},
schema={"f": {"format": "I", "bscale": 0.5, "bzero": 10.0}},
)
torchfits.table.replace_column("s.fits", "f", [11.5, 12.0])
assert torchfits.table.read_arrow("s.fits").column("f").to_pylist() == [11.5, 12.0]
import numpy as np
import torchfits
torchfits.table.write("t.fits", {"a": np.arange(3, dtype=np.int64)}, overwrite=True)
u16 = np.array([0, 40000, 65535], dtype=np.uint16)
torchfits.table.replace_column("t.fits", "a", u16) # now TFORM 'I', TZERO 32768
assert torchfits.read("t.fits", hdu=1)["a"].dtype.is_floating_point is False
torchfits.table.replace_column("t.fits", "a", u16, format="1J") # plain int32
String (A) columns take ASCII text in every row and column mutation:
non-ASCII str or bytes values raise ValueError (as table.write does),
None is written as a blank value, and a value longer than the column width
is clipped with a RuntimeWarning.
Interop¶
Astropy¶
Direct reading, conversion, and writing for astropy.table.Table. Astropy is not a torchfits dependency; install it before calling these:
# Read directly into an Astropy Table (supports SQL WHERE filters)
tbl = torchfits.table.read_astropy(
"catalog.fits", hdu=1, where="MAG_G < 20.0 AND DEC > 0"
)
# Convert an in-memory tensor dict to an Astropy Table
tbl_from_tensors = torchfits.to_astropy(tensor_dict)
# Write an Astropy Table directly to FITS
torchfits.table.write("out.fits", tbl, overwrite=True)
Polars¶
Native DataFrame path — FITS table → Polars dataframe in one call.
# One-call FITS to Polars (preserves FITS metadata)
df = torchfits.table.read_polars("catalog.fits", hdu=1)
# df: FITSPolarsFrame — wraps pl.DataFrame with .field_meta, .table_meta
# Streaming FITS to Polars (no full materialization)
for batch in torchfits.table.scan_polars("catalog.fits", hdu=1):
process(batch) # pl.DataFrame
# Eager Polars then LazyFrame (materializes once)
lazy = torchfits.table.to_polars("catalog.fits", hdu=1).lazy()
# From table dict
polars_df = torchfits.to_polars(table_dict, decode_bytes=False)
True streaming
scan_polars() yields one DataFrame per batch without building the full
table. For a LazyFrame over an already-materialized table, use
to_polars(...).lazy() or pl.concat(scan_polars(...)).lazy().
rechunk=False default
All Polars conversion functions default to rechunk=False. Pass
rechunk=True to restore the old chunk-concatenation behavior.
DuckDB¶
import duckdb
# to_duckdb registers the table and returns a DuckDB relation (DuckDBPyRelation).
# Pass connection= to run SQL against the registered name on your own connection.
con = duckdb.connect()
rel = torchfits.table.to_duckdb(
"catalog.fits", hdu=1, relation_name="tbl", connection=con
)
bright = rel.filter("MAG < 20")
counts = con.sql("SELECT count(*) FROM tbl WHERE MAG < 20").fetchall()
# One-shot query: registers the file under relation_name on a fresh connection
result = torchfits.table.duckdb_query(
"catalog.fits",
"SELECT * FROM tbl WHERE MAG < 20",
hdu=1,
relation_name="tbl",
)
# result: pyarrow object (Table or RecordBatchReader by version);
# call result.read_all() to force a pyarrow.Table
Arrow and Pandas¶
arrow_table = torchfits.to_arrow(table_dict, decode_bytes=False)
pandas_df = torchfits.to_pandas(table_dict, decode_bytes=False)
Schema¶
Infer an Arrow schema from FITS TFORM / TTYPE cards (no data read when
where=None).
torchfits.table.schema(
path,
hdu=1,
columns=None,
where=None,
decode_bytes=True,
encoding="ascii",
strip=True,
include_fits_metadata=False,
apply_fits_nulls=False,
backend="auto",
)
Returns: pyarrow.Schema
schema = torchfits.table.schema("catalog.fits", hdu=1)
Additional Utilities¶
# PyArrow dataset and scanner
ds = torchfits.table.dataset("catalog.fits", hdu=1)
sc = torchfits.table.scanner("catalog.fits", columns=["RA", "DEC"])
# Parquet export -- `dest` is the output path; I/O kwargs (columns, where,
# hdu, ...) are forwarded to the read, so exports can be filtered and
# projected without materialising the whole table first.
torchfits.table.write_parquet("out.parquet", "catalog.fits", hdu=1)
torchfits.table.write_parquet("bright.parquet", "catalog.fits", where="MAG < 20")
torchfits.table.write_csv("bright.csv", "catalog.fits", where="MAG < 20")
torchfits.table.write_ipc("bright.arrow", "catalog.fits", where="MAG < 20")
# Cache cleanup
torchfits.table.clear_cache()
The public constant torchfits.table.TABLE_BACKENDS lists recognized table
backend names for callers that select an explicit backend.
torchfits.table.write_ipc(dest, data, *, stream=False, compression="zstd",
**read_kwargs) writes Arrow IPC/Feather V2. data may be a FITS path, Arrow
table, record-batch reader, or batch iterable. For a FITS path, read keywords
such as hdu, columns, and where are forwarded to table.read or
table.reader; keywords are rejected for already-materialized input. Set
stream=True to write batches without materializing the full table.