Migrating from fitsio (or astropy)¶
rustfits is the modern successor to fitsio — same primary author, written in Rust+PyO3 with a cleaner Python surface and significantly better performance for many workloads. fitsio remains stable and installable indefinitely; if your existing pipeline works, you don’t need to migrate. This page is for users who want to migrate, or for new code that wants the modern API. See Why a new package toward the bottom for the longer rationale.
If you’re coming from astropy.io.fits, skip ahead to
From astropy.io.fits — the differences look different there.
What’s the same¶
The high-level shape is deliberately close to fitsio:
Open a file with
rustfits.FITS(path); use as a context manager.Index HDUs with
fits[i](integer position) orfits["name"](EXTNAME, case-insensitive).Read with
hdu.read(); subset tables withhdu.read(columns=..., rows=...).Top-level
rustfits.read(path)andrustfits.read_header(path)mirror fitsio’sfitsio.read/fitsio.read_header.Accessing column and row subsets on the hdu:
hdu[rows],hdu[colnames][rows]
The big idea — “open, index, read” — translates one-for-one.
What’s different¶
Two things are worth a careful look before migrating: headers and compression kwargs.
Headers¶
This is the biggest difference and the one most likely to surface in migrated code.
fitsio treats headers as a sequence of records. A header
record carries name, value, and comment together; iterating a
header yields the records; hdr[key] returns the value and
hdr.get_comment(key) is a separate call.
rustfits treats headers as a parse-on-demand view of the
underlying cards. hdr[key] returns the value;
hdr.comment_of(key) returns the comment; hdr.keys() and
key in hdr follow dict semantics. See Headers for the
full picture.
Side-by-side:
# fitsio
import fitsio
with fitsio.FITS(path) as f:
hdr = f[0].read_header()
exptime = hdr["exptime"]
comment = hdr.get_comment("exptime")
for record in hdr.records():
print(record["name"], record["value"], record["comment"])
# rustfits
import rustfits
with rustfits.FITS(path) as fits:
hdr = fits[0].header # attribute, not method
exptime = hdr["exptime"] # case-insensitive
comment = hdr.comment_of("exptime")
for key in hdr.keys():
print(key, hdr[key], hdr.comment_of(key))
Two specific points worth knowing:
hdu.headeris an attribute, not a method (read_header()doesn’t exist on the HDU). The top-levelrustfits.read_header()exists for the “just-the-header-from-a-filename” use case.The returned
rustfits.FITSHeadersurvives the file close. Read-only access works after thewithblock exits.
Compression kwargs¶
fitsio takes flat kwargs:
fits.write(data, compress="GZIP_1", tile_dims=(100, 100),
qlevel=4.0, qmethod=1)
rustfits also takes string forms such as “GZIP_1”, but to get non-default behavior it takes a structured config object:
fits.write_image(
data,
compress=rustfits.Gzip1(tile_shape=(100, 100), level=9),
quantize=rustfits.Quantize(level=4.0, method="dither1"),
)
One config class per algorithm: Gzip1,
Gzip2, Rice1,
Hcompress1, Plio1. Each
takes the parameters that algorithm actually needs (RICE has
blocksize, HCOMPRESS has scale and smooth, GZIP has
level, everyone has tile_shape and heap_format).
If you want the cfitsio defaults without thinking about which
algorithm, compress=True (table) or compress="GZIP_1"
(string alias) both work. See Compression for the full
surface.
Quantization split out into its own Quantize config means
the default is now lossless float compression — call out
Quantize(level=N) explicitly when you want lossy. fitsio’s
default is the opposite (lossy with cfitsio’s defaults); be
deliberate when porting.
A thin fitsio-style shim¶
If you want to try out rustfits on an existing codebase, rustfits ships a thin shim that might get you started. It works for basic patterns
from rustfits import fitsio
with fitsio.FITS(path, "rw", clobber=True) as fits:
fits.write_image(data, extname="sci", compress="RICE_1")
The shim translates fitsio’s mode='r' | 'rw' (plus the
'READONLY' / 'READWRITE' / 0 / 1 synonyms) and the
clobber= kwarg to rustfits’s native modes; everything else is
the real rustfits.FITS object, so indexing, iteration,
hdu.read() / hdu.write(), the hdu.header accessor and
so on behave as rustfits-native, not as fitsio.
What it does NOT translate: vstorage= / case_sensitive= /
upper= / lower= / where= kwargs (use the recipes
below), and hdu.header returns a rustfits.FITSHeader
rather than fitsio’s FITSHDR (hdr[key] and key in hdr
work; hdr.records() does not — see Headers). The
shim is deliberately narrow — it gets you past the constructor
and onto the rustfits surface; the rest of this page covers
porting strategies and additional differences.
Common porting recipes¶
Open + read¶
# fitsio
data = fitsio.read(path, ext=1)
# rustfits — identical
data = rustfits.read(path, ext=1)
Open + read with row / column subset¶
fitsio exposes rows= / columns= on the top-level
fitsio.read. rustfits made rustfits.read minimal —
open the file explicitly for these:
# fitsio
tab = fitsio.read(path, ext=1, columns=["ra", "dec"], rows=[0, 5, 10])
# rustfits
with rustfits.FITS(path) as fits:
tab = fits[1].read(columns=["ra", "dec"], rows=[0, 5, 10])
Write an image to a fresh file¶
# fitsio
with fitsio.FITS(path, "rw", clobber=True) as f:
f.write(data, extname="SCI")
# rustfits — explicit handle for type-specific kwargs
with rustfits.FITS(path, "w+") as fits:
fits.write(data, extname="sci")
# for non-universal keywords, use write_image
fits.write_image(data, blank=-1)
# rustfits — minimal one-liner, auto-dispatches by data type
rustfits.write(path, data, extname="sci")
Note "w+" is the rustfits equivalent of fitsio’s
"rw" + clobber=True. Use "r+" to append to an
existing file.
Write a table to a fresh file¶
# fitsio
with fitsio.FITS(path, "rw", clobber=True) as f:
f.write(table, extname="cat")
# rustfits — explicit, gets table-side kwargs
with rustfits.FITS(path, "w+") as fits:
fits.write(table, extname="cat")
# for non-universal keywords, use write_table
fits.write_table(table, bit_columns=['x'])
# rustfits — minimal, auto-dispatches by data type
rustfits.write(path, table, extname="cat")
Compressed image¶
# fitsio
with fitsio.FITS(path, "rw", clobber=True) as f:
f.write(data, compress="RICE_1", tile_dims=(100, 100))
# rustfits also takes the string form, but for more control
# use a structured config class
with rustfits.FITS(path, "w+") as fits:
fits.write_image(
data,
compress=rustfits.Rice1(tile_shape=(100, 100)),
)
Lossy float compression¶
# fitsio
with fitsio.FITS(path, "rw", clobber=True) as f:
f.write(data, compress="GZIP_2", qlevel=4.0)
# rustfits — quantize MUST be explicit; default is lossless
with rustfits.FITS(path, "w+") as fits:
fits.write_image(
data,
compress=rustfits.Gzip2(),
quantize=rustfits.Quantize(level=4.0),
)
Read header only¶
# fitsio
hdr = fitsio.read_header(path) # primary
hdr = fitsio.read_header(path, ext=1) # by index
# rustfits — identical surface
hdr = rustfits.read_header(path)
hdr = rustfits.read_header(path, ext=1)
What rustfits doesn’t have¶
For an explicit list with workarounds, see Known limitations. Highlights for fitsio users:
fitsio’s ``vstorage=”object”`` vs ``”fixed”``. fitsio offers a mode where each variable-length cell becomes a fixed-size N-D array padded to the longest cell. rustfits always returns one ndarray per row (Object dtype). Build the padded form yourself if needed.
fitsio’s ``case_sensitive=True`` column lookup. rustfits is always case-insensitive on column names; case is preserved on disk but lookup folds case.
What fitsio doesn’t have that rustfits does¶
These are the big ergonomic and capability wins worth the migration:
Full ``__getitem__`` / ``__setitem__`` surface on every HDU.
rustfits has symmetry between read and write, letting you modify in place as with a numpy array — slice an image, replace a column, patch individual rows or ranges, all without rewriting the file:
# Image: cutouts and in-place pixel writes.
stamp = hdu[100:200, 50:150] # read just the cutout
hdu[100:200, 50:150] = 0 # zero out that region
hdu[42, :] = np.arange(hdu.shape[1]) # rewrite one row
# Table: every shape of row / column / cell write.
hdu[5] = new_record # replace one row
hdu[100:200] = new_rows # slice replace
hdu[[1, 3, 5]] = new_rows # fancy-row replace
hdu["flag"] = np.zeros(len(hdu), dtype="i4") # whole-column replace
hdu[["ra", "dec"]] = subset_array # multi-column replace
hdu["ra"][5] = 123.4 # single cell
hdu["ra"][[0, 1, 2]] = [10.0, 11.0, 12.0] # rows of one column
hdu[["ra", "dec"]][0:10] = sub_array # rows of a column subset
All of these work on both fixed and variable-length columns, on both compressed and uncompressed tables. See Tables and Images for the full picture.
Tile-compressed table writes (ZTABLE).
fitsio reads tile-compressed tables but doesn’t write them.
rustfits writes them with the full __setitem__ /
append / repack surface, including VLA columns.
Significantly faster compressed reads.
Up to 40× faster than fitsio on small-chunk workloads, 3.2×
faster on large-chunk workloads — see the “Performance”
discussion in CLAUDE.md for the benchmark setup. fitsio’s
large-array reads are already fast; the wins are mostly in
the access patterns that decompress only the overlapping
tiles.
Why a new package¶
The user-visible wins above are the surface of a deeper project-level shift. Two things stand behind them.
Rust is not just a language choice. Its memory-safety guarantees rule out entire classes of bugs that affect binary-format parsers written in C — buffer overflows, use-after-free, data races between threads, the lot. The crate ecosystem matters just as much: low-level building blocks like gzip framing, deflate compression, and CRC32 land as one-line Cargo dependencies rather than vendored C libraries or system-library version hunting. That makes it cheap to use well-tested, broadly-deployed implementations and to swap them out as the ecosystem evolves.
rustfits’s Rust backend is developed alongside the Python wrapper by the same authors. Implementation improvements — bug fixes, new compression features, performance work — land rapidly and on demand. fitsio, by contrast, depends on the cfitsio C library for low-level FITS handling. cfitsio is mature, widely deployed, and appropriately conservative — its maintainers reasonably prioritize stability over new features, which is the right call for a foundational dependency of the wider ecosystem. The practical effect is just an impedance mismatch: features that wouldn’t fit cfitsio’s stability mandate can still land in rustfits when there’s a clear need.
None of this means you have to migrate. fitsio remains stable and installable indefinitely; existing pipelines do not need to change. But for new code, or for projects that want features cfitsio doesn’t expose, rustfits is the place to go.
From astropy.io.fits¶
If you’re coming from astropy, the conceptual gap is bigger because astropy’s surface uses different abstractions. The quick translations:
fits.open(path)→rustfits.FITS(path)hdul[i].data→hdul[i].read()(rustfits doesn’t auto-load data on open)hdul[i].header→hdul[i].header(same attribute name; rustfits’sFITSHeaderhas a slightly different surface — see Headers)Table.read(path)→rustfits.read(path)for the simple case; open the file for finer controlCompImageHDU(...)→FITS.write_image()with thecompress=config object
The biggest mental shift coming from astropy: rustfits doesn’t build Python objects for every card. The header is a dict-like view over the raw card list, and the data is read on demand. This is closer to fitsio’s model than astropy’s.