Żmij for R: Floating-point to string conversion with round-trip guarantees

Photo by Claire Satera. Stylized with Paper Shaders halftone dots filter.
Photo by Claire Satera. Stylized with Paper Shaders halftone dots filter.

I’m glad to share that zmij is now on CRAN. zmij converts doubles to decimal strings and back, using the fewest digits needed to recover the original values. It brings Victor Zverovich’s zmij algorithm to R, via David Tolnay’s Rust zmij port, wrapped in a vectorized interface with extendr.

You can install it from CRAN with:

install.packages("zmij")

Why bother?

I like storing data as plain text when possible: humans can read it, diff it, version control it, and edit it with any text editor. However, floating-point numbers make this harder than it should be. By default, R converts them to strings using 15 significant digits and quietly loses information:

x <- c(pi, 0.1, 1 / 3)
x |> as.character()
#> [1] "3.14159265358979" 
#> [2] "0.1"              
#> [3] "0.333333333333333"
x |> as.character() |> as.numeric() |> identical(x)
#> [1] FALSE

You can do a round-trip conversion reliably by asking for 17 significant digits, but then you get this:

x |> sprintf(fmt = "%.17g")
#> [1] "3.1415926535897931" 
#> [2] "0.10000000000000001"
#> [3] "0.33333333333333331"
x |> sprintf(fmt = "%.17g") |> as.numeric() |> identical(x)
#> [1] TRUE

So the traditional choice was between losing precision, bloating your files with digit noise, or giving up on plaintext workflow entirely and using a binary format like .rds, .parquet, or .safetensors.

The dtoa (double to ASCII) algorithms eliminate this trade-off. For every floating-point number, it finds the shortest decimal string that parses back to exactly the same value: 0.1 stays "0.1", while pi keeps all 16 digits it genuinely needs. At the moment, zmij also happens to be the fastest known algorithm to do so with proven correctness: Victor’s blog post has the benchmarks and the tricks behind them. From R, crossing the language boundary will absorb most of those nanoseconds, which I can live with. The round-trip guarantee is what I came for.

Usage

zmij provides two functions. format_double() turns doubles into strings, and parse_double() reverses it:

library(zmij)
x <- c(pi, 0.1, 1 / 3, .Machine$double.xmax)
x |> format_double()
#> [1] "3.141592653589793"      
#> [2] "0.1"                    
#> [3] "0.3333333333333333"     
#> [4] "1.7976931348623157e+308"
x |> format_double() |> parse_double() |> identical(x)
#> [1] TRUE

That TRUE holds for every finite double, meaning your numbers can survive a round-trip through any text file untouched:

path <- tempfile()
x |> format_double() |> writeLines(path)
path |> readLines() |> parse_double() |> identical(x)
#> [1] TRUE

Both functions are vectorized and preserve names and dimensions, so matrices and arrays pass through with their shape intact. Missing values and non-finite values also get explicit representations:

c(NA_real_, NaN, Inf, -Inf) |> format_double()
#> [1] NA     "NaN"  "inf"  "-inf"

parse_double() is useful in its own right beyond undoing format_double(): it uses Rust’s correctly rounded parser, which behaves consistently across platforms even at the far edges of the double range:

c("5e-324", "1.7976931348623157e+308") |> parse_double()
#> [1] 4.940656e-324 1.797693e+308

Development notes

zmij is my first package built with extendr. extendr scaffolds a Rust-powered R package and handles the plumbing between the two languages. The experience was pleasantly boring: most of my time went into building the Rust wrapper logic, and almost none into the bridge or packaging.

One piece of developer tooling deserves a mention. My r-rust-pkgs project tracks every CRAN package that uses Rust, and it now comes with a script that turns that list into something directly useful:

git clone https://github.com/nanxstats/r-rust-pkgs.git
cd r-rust-pkgs
Rscript scripts/monorepo.R

This downloads the source of all those packages into a single “synthetic monorepo” under references/. Move that directory into your own package (remember to add it to .gitignore and .Rbuildignore) and your AI coding agents have a greppable record of how the entire Rust-in-R community has solved the problems you might hit. When zmij failed to build on win-builder, a quick grep showed other packages had already patched their Makevars templates for the same issue, and the fix (nanxstats/zmij#6) only took a few minutes to implement.

Thanks to Victor Zverovich for the algorithm and to David Tolnay for the Rust crate. If you find a number the package mishandles, please create an issue.