A safe, ergonomic, and pure-Rust library for reading and writing FITS (Flexible Image Transport System) files, inspired by CFITSIO.
This crate offers optional async I/O with Tokio and structured access to FITS headers, images, and tables — without any C dependencies.
Designed for astronomy, astrophotography, and scientific pipelines where portability and safety matter.
- 📦 Pure Rust implementation (no CFITSIO, no C bindings)
- ⚡ Async I/O with Tokio (enabled by default)
- 🧩 Support for Primary HDUs and extensions
- 🖼️ Image HDUs of any dimensionality, cubes and hypercubes included
- 📊 Binary and ASCII tables alike, with optional
serdesupport in both directions - 🗜️ Tile-compressed images, read and written as images like any other
- 🌍 World coordinate helpers: pixels to sky positions and back, with SIP and TPV distortions
- 🧠 Typed access to FITS header keywords, and any keyword at all by name
- 🚀 Streaming and memory-efficient reads
- 🛡️ Idiomatic error handling with Result
- 🔁 CFITSIO-inspired API, redesigned for Rust
Add the crate to your Cargo.toml:
[dependencies]
fits-io = "0.2"# #[cfg(feature = "fs")]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::Fits;
use fits_io::fs::FsFits;
use fits_io::hdu::ImageHDU;
let fits = FsFits::open("observation.fits".as_ref())?;
let hdu = fits.primary_hdu();
println!("{} x {}", hdu.images_width(), hdu.images_height());
if let Some(image) = hdu.read_image(0)? {
let normalised = image.normalized();
println!("first pixel: {}", normalised.get_pixel(0, 0)[0]);
}
# Ok(())
# }
# fn main() {}With the serde feature, a table's rows deserialize straight into a struct,
matching columns to fields by their TTYPEn names. The same works for an ASCII
table through read_rows, from_ascii_table and to_ascii_table.
# #[cfg(all(feature = "fs", feature = "serde"))]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::Fits;
use fits_io::fs::FsFits;
use fits_io::hdu::{BinTableHDU, ExtensionHDU};
use serde::Deserialize;
#[derive(Deserialize)]
struct Source {
#[serde(rename = "RA")]
right_ascension: f64,
#[serde(rename = "DEC")]
declination: f64,
// A column with a TNULLn card may leave entries undefined.
#[serde(rename = "MAG")]
magnitude: Option<f32>,
}
let fits = FsFits::open("catalogue.fits".as_ref())?;
if let Some(ExtensionHDU::BinTable(hdu)) = fits.extension_hdu(0) {
let sources: Vec<Source> = hdu.read_rows()?;
println!("{} sources", sources.len());
}
# Ok(())
# }
# fn main() {}Setting data also brings the header into line with it, and saving fills in the mandatory cards, puts them in the order the standard requires, and writes CHECKSUM and DATASUM.
# #[cfg(feature = "fs")]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::Fits;
use fits_io::fs::FsFits;
use fits_io::hdu::ImageHDU;
let mut fits = FsFits::open("observation.fits".as_ref())?;
fits.primary_hdu_mut()
.set_raw_images_i16(2, 2, &[&[1, 2, 3, 4]])?;
// `to_vec` returns the bytes; `save` writes them back over the file.
let bytes = fits.to_vec()?;
fits.save()?;
# Ok(())
# }
# fn main() {}Every keyword this crate knows has an accessor of its own, and any keyword at
all can be set by name. A keyword too long for the eight columns a card gives it
becomes a HIERARCH card, and a value too long for one card is written across
CONTINUE cards.
# fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::header::{Header, Value};
let mut header = Header::default();
header.set_card("OBJECT", "NGC 7000")?;
header.set_card("EXPTIME", Value::from(300.0).with_comment("seconds"))?;
header.set_card("ESO INS FILT1 NAME", "Halpha")?;
header.add_history("stacked from 42 subframes");
// The typed accessors see what was set by name.
assert_eq!(header.object(), Some("NGC 7000"));
# Ok(())
# }An image can be stored tile-compressed, the way fpack writes one: cut into
tiles, each tile compressed on its own, and the result written as a binary table
that a reader treats as the image it stands for. Everything that reads an image
goes on working.
# #[cfg(feature = "gzip")]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::hdu::ImageHDU;
use fits_io::image::compression::{Compression, CompressionOptions, Quantize};
use fits_io::{Fits, FitsSlice};
let mut fits = FitsSlice::new();
fits.primary_hdu_mut()
.set_raw_images_i16(2, 2, &[&[1, 2, 3, 4]])?;
fits.primary_hdu_mut()
.compress(&CompressionOptions::new(Compression::Rice))?;
let bytes = fits.to_vec()?;
// A floating point image has to be quantised before an integer coder can take
// it, which loses the low bits of every pixel; `Compression::Gzip` compresses
// one as it stands and loses nothing.
let lossy = CompressionOptions::new(Compression::Rice)
.with_quantization(Quantize::NoiseLevel(4.0));
# Ok(())
# }
# fn main() {}# #[cfg(feature = "serde")]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::bin_table::to_bin_table;
use fits_io::hdu::{BinTableHDU, ExtensionHDU, ImageHDU};
use fits_io::{Fits, FitsSlice, SliceBinTableHDU};
use serde::Serialize;
#[derive(Serialize)]
struct Star {
#[serde(rename = "NAME")]
name: String,
#[serde(rename = "MAG")]
magnitude: f64,
}
let mut fits = FitsSlice::new();
fits.primary_hdu_mut()
.set_raw_images_u8(2, 2, &[&[1, 2, 3, 4]])?;
// Column types and widths are worked out from the rows themselves.
let stars = vec![Star {
name: "Vega".into(),
magnitude: 0.03,
}];
let table = SliceBinTableHDU::from_table(&to_bin_table(&stars)?)?;
fits.push_extension(ExtensionHDU::BinTable(table));
let bytes = fits.to_vec()?;
# Ok(())
# }
# fn main() {}FitsSlice reads a file that is already in memory — one arriving over a
network, say, or a build with the fs feature turned off. It reads, writes and
streams the same things FsFits does, and from_vec takes over your buffer
rather than copying it. A gzipped buffer is decompressed transparently.
use fits_io::{Fits, FitsSlice};
use fits_io::hdu::ImageHDU;
# fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
# let mut bytes = format!("{:<80}{:<80}{:<80}{:<80}{:<80}{:<80}",
# "SIMPLE = T", "BITPIX = 8",
# "NAXIS = 2", "NAXIS1 = 2",
# "NAXIS2 = 2", "END").into_bytes();
# bytes.resize(2880, b' ');
# bytes.extend_from_slice(&[1, 2, 3, 4]);
# bytes.resize(5760, 0);
let fits = FitsSlice::from_slice(&bytes)?;
assert_eq!(fits.primary_hdu().image_count(), 1);
# Ok(())
# }default-features = false gives you header, image and table parsing over
in-memory data through FitsSlice, with no filesystem, async or threading
support.
| Feature | Default | Effect |
|---|---|---|
fs |
✅ | Read and write FITS files on the filesystem via FsFits |
gzip |
✅ | Transparently decompress gzipped files and buffers |
tokio |
✅ | Async open and streaming reads |
rayon |
✅ | Parallel table row decoding, worth about 4x on a big table |
serde |
Convert table rows to and from your own structs |
cargo bench --features fs,serde,rayon times opening a file, reading a table
and decoding its rows, against the Gaia fixture under tests/. It reports the
fastest of several runs and skips when the fixture is absent.
What has changed between releases is in CHANGELOG.md.
Every public item is documented, and #![deny(missing_docs)] keeps it that way.
The API docs are on docs.rs, built with every feature
enabled so the serde, tokio and gzip parts are visible.
- Safety — eliminate undefined behavior and unsafe FFI
- Portability — run anywhere Rust runs
- Ergonomics — minimal boilerplate
- Performance — streaming-friendly, low overhead
- Familiarity — CFITSIO-inspired, Rust-native
| Feature | Status | Notes |
|---|---|---|
| Primary HDU | ✅ | |
| Extension HDUs | ✅ | |
| Image HDU | ✅ | Any number of axes; planes beyond the second are indexed |
| Binary tables | ✅ | serde converts rows to and from your structs |
| ASCII tables | ✅ | Read, written, streamed and serde-mapped like binary ones |
| Variable-length array columns | ✅ | TFORMn P and Q, read and written through the heap |
| Complex columns | ✅ | TFORMn C and M |
| Column scaling | ✅ | TSCALn, TZEROn and TNULLn applied both ways |
| Unsigned columns | ✅ | The TZEROn convention, at all four integer widths |
| Multidimensional columns | ✅ | TDIMn read and written, nesting as deep as it says |
| Undefined image pixels | ✅ | BLANK reads as NaN rather than as black |
| Header read | ✅ | CONTINUE long values and HIERARCH keywords included |
| Header write | ✅ | Mandatory cards filled in and ordered as the standard asks |
| Header editing | ✅ | set_card and remove_card by keyword, writing HIERARCH and CONTINUE as needed |
| Image write | ✅ | Any number of axes, then save or to_vec |
| Table write | ✅ | set_table / set_rows, for both table kinds |
| Building files | ✅ | push_extension and remove_extension |
| Gzip decompression | ✅ | .fits.gz files and gzipped buffers alike |
| Streaming image reads | ✅ | stream_normalised_image, via the tokio feature |
| Streaming table rows | ✅ | stream_table_rows, via the tokio feature |
| WCS conventions | ✅ | CDi_j, PCi_j and CDELTn/CROTAn, with LONPOLE and LATPOLE |
| WCS projections | ✅ | TAN, SIN, ARC, STG, ZEA, CAR, MER, CEA, AIT, MOL |
| WCS distortions | ✅ | SIP and TPV, both applied and inverted |
| Non-celestial axes | ✅ | A cube's third axis and beyond, linear or -LOG |
| CHECKSUM and DATASUM | ✅ | Written on save; checksum::verify checks an HDU |
| Random groups | ✅ | group_count and read_group, with PSCALn and PZEROn |
| Compressed image read | ✅ | RICE_1, HCOMPRESS_1, PLIO_1, GZIP_1/2, NOCOMPRESS, any number of axes |
| Compressed image write | ✅ | RICE_1, HCOMPRESS_1, PLIO_1, GZIP_1/2 and NOCOMPRESS, through ImageHDU::compress |
| Quantised floating point | ✅ | ZQUANTIZ dithering read and written, ZBLANK included |
| HCOMPRESS smoothing | ✅ | SMOOTH honoured, matching the reference implementation |
| Conic WCS projections | 🚧 | COE, COD and the rest say so rather than guessing |
Licensed under either of:
- Apache License, Version 2.0
- MIT License
at your option.
Issues, discussions, and pull requests are welcome. Please open an issue for large changes or new features.
Inspired by CFITSIO and the FITS standard maintained by NASA/HEASARC.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.