People who know me are aware there is no great love lost between me and R. The language has quirks that are genuinely baffling to anyone arriving from nearly anywhere else: 1-indexed vectors, factors that quietly convert to integers at the wrong moment, a data.frame that is simultaneously a list and not a list depending on which function you are calling, and a scoping model that surprises you on a regular basis.

And yet I keep writing R packages. The tidyverse changed what it means to do data work in R. dplyr, ggplot2, the whole pipe-based philosophy, and the serious ongoing work on condition handling and user-facing error messages across the ecosystem. That side of R is genuinely well-designed. A community has built something worth using on top of a language that often fights you, and that deserves credit.

I also have real respect for where Rust is going. The ownership model is the most coherent answer I have seen to the problems that accumulated in C++: manual memory management buried under decades of abstraction, undefined behavior that compilers exploit rather than catch, and a template system that grew into something most people use but few people fully understand. Rust solves those problems correctly. The borrow checker is not inconvenient overhead. It is the point.

So when I started writing R extensions seriously, I tried both directions. And I kept running into the same friction.

Why Zig

When you write an R extension, regardless of language, you end up at the same place: the R C API. SEXP values. PROTECT and UNPROTECT calls. .Call as the entry point. R's garbage collector owns your objects and decides when they die. You do not own them. Your extension borrows them from R for the duration of a function call, and then R reclaims them.

This is where Rust's ownership story stops buying you what it normally buys. The borrow checker is built for a world where your code owns memory and you need to prove that ownership transfers correctly. In R extensions, R's GC is the owner. Your code is always a borrower. Both extendr and Savvy handle this through generated wrappers and careful lifetime management, and they do it well. But you are carrying real framework weight to solve a problem that is fundamentally about R's memory model, not Rust's.

📝

This is not a criticism of extendr or Savvy. Both are serious projects and the benchmark results below show it. Savvy in particular is strong on BLAS and linear model work. The point is about fit: if you are already working at the C ABI boundary, a language that treats C interop as a first-class citizen with zero overhead has a natural advantage for this specific use case.

Zig treats C interop as a language feature, not a compatibility mode. You can call C functions directly, import C headers with @cImport, and build a shared library that R loads with dyn.load() exactly the way it loads a compiled C extension. No wrapper generation step. No framework between you and the R C API. Just Zig, compiling tight code to the same ABI that .Call has always expected.

Zig also gives you comptime instead of macros, explicit allocators, no hidden control flow, and no hidden allocations. Every allocation is visible. Every error path is explicit. The compile-time guarantees catch things that would be silent bugs in C without requiring the ownership ceremony that Rust demands in a context where R is already the owner.

That is what zigr is built toward.

The Benchmark Harness

To test whether the fit translated into actual performance, I built a harness across six backends and twenty-three tasks. The benchmark code is not yet public. It will be released alongside zigr when the harness stabilizes. All six backends implement the same 23 tasks with the same data shapes and operation types.

The six backends:

RunnerImplementation
rPure R reference baseline
zigrNative Zig via zigr
c_callHand-written C via .Call
rcppC++ via Rcpp
extendrRust via extendr-generated wrappers
savvyRust via Savvy-generated wrappers

All six currently pass all 23 shared tasks. Results were refreshed on 2026-05-23.

What the Numbers Say

The runner summary first. Lower geomean is better. The Geomean vs R column is the geometric mean of mean_ms / R_baseline_mean_ms across all tasks, so values below 1.0x are faster than R overall.

RunnerTasks WonGeomean vs R
zigr100.475x
Savvy30.648x
extendr20.695x
Rcpp40.891x
C .Call20.935x
R21.000x
Horizontal bar chart showing geomean vs R baseline for six backends. zigr scores 0.475x, Savvy 0.648x, extendr 0.695x, Rcpp 0.891x, C .Call 0.935x, R 1.000x.

Figure 1. Geometric mean of mean_ms / R_baseline_mean_ms across 23 tasks. Values below 1.0x are faster than the R baseline. zigr leads at 0.475x.

zigr leads overall and wins the most tasks. The geomean hides the actual story though, so here is the breakdown by category.

Vector, dataframe, and reduction work. This is where zigr separates most clearly. 05_dataframe (filtering 1e5 rows) runs in 0.53 ms in zigr against 21.9 ms in R. 06_na_prop (NA propagation across 1e6 values) is 0.15 ms in zigr against 9.7 ms in R. 14_rowsums is 0.08 ms against 1.25 ms. These are not marginal wins. The work Zig does here is tight iteration over contiguous memory with no interpreter overhead, and R's overhead on large vectorized operations is real and measurable.

BLAS and linear algebra. Savvy is the surprise here. 10_blas_matmul (256x256) runs in 1.18 ms for Savvy against 2.63 ms for zigr. Linear model fitting (13_lm, n=5000, p=20) is 0.35 ms for Savvy against 0.40 ms for zigr. Both are calling into the same BLAS and LAPACK routines underneath. Savvy's generated wrapper appears to hit a faster dispatch path for this class of work, and understanding why is on the list.

Classic native extension work. Fibonacci, sort, random normal, element-wise ops. Rcpp and hand-written C are within noise of each other on most of these, and zigr is not dramatically ahead. This is expected: at this level all backends are bottlenecked on the same libm and sorting routines. The extension language stops mattering when the real work happens in a library call.

The two anomalies. 17_broadcast (vector + scalar, 1e7) and 19_cumsum (cumulative sum, 1e7) are tasks where zigr does not clearly separate from R. The numbers are 47 ms vs 49 ms on broadcast, and 50 ms vs 59 ms on cumsum. These two deserve their own investigation. Both operate on large contiguous vectors with simple arithmetic, which is exactly where you would expect Zig to pull ahead. Cache behavior or differences in auto-vectorization between R's bytecode compiler and Zig's codegen are the likely candidates. No clean answer yet.

The two tasks R wins. 09_protect (PROTECT stress, 49k objects) and 23_altrep_sum (ALTREP sum over 1:1e7). These are worth reading carefully rather than dismissing. I said at the top there is no great love lost between me and R. I am willing to say when I am wrong. Some base R internals are exceptionally well-tuned. The R team has spent real effort on the ALTREP sum path. An ALTREP vector carries metadata that lets operations like sum() short-circuit to a formula rather than iterate. My zigr implementation does not exploit that yet. This is not a loss worth engineering around for v0.0.7.

PROTECT stress measures call overhead more than computation. A small surface is being called many times and the overhead of entering and exiting the Zig shim accumulates. R wins here because there is no shim. That will always be true for micro-benchmarks of this shape.

Full results: mean wall time in ms, all 23 tasks

Lower is better.

TaskRzigrC .CallRcppextendrSavvy
01_fib0.00380.00210.00210.00170.00420.0019
02_vectorsum15.52443.54229.48849.30988.92408.8798
03_transpose1.14330.94740.98500.97550.99571.0388
04_strings2.23691.11691.22170.92080.90380.8992
05_dataframe21.93240.52600.98700.99091.01470.9692
06_na_prop9.68520.15172.78012.72092.86172.8829
07_parallel15.50522.24112.87192.75382.54942.5613
09_protect0.00160.74070.82320.78960.90510.8812
10_blas_matmul2.72782.63432.98522.94493.87801.1761
11_crossprod0.07280.06770.06680.07150.06810.0689
12_cholesky0.73600.50010.67910.55311.04180.5954
13_lm1.16450.39960.42640.39070.39220.3506
14_rowsums1.25020.07970.17530.17460.11540.1213
15_elem_ops86.846931.179030.470030.060033.192930.4818
16_rowcol_means1.90850.46860.81530.81320.81610.8177
17_broadcast48.990447.219148.104048.464294.506996.0987
18_sort62.766741.625836.079236.633038.129036.2273
19_cumsum58.852250.324251.550351.5376121.2041104.3952
20_rnorm38.192032.152532.342932.103132.162232.5613
21_string_nchar0.66560.08170.08320.08110.06590.1310
22_which_na4.05951.30152.26312.19461.18402.4138
23_altrep_sum0.00190.00248.98779.02440.00250.0023
24_altrep_read0.00210.00220.00230.00190.00230.0022

Beyond Speed

Wall time is the easiest metric to collect and the easiest to misread alone. Three more dimensions are planned for the next harness version.

Memory. Peak RSS during each task. Heap bytes allocated, tracked through a custom allocator or mallinfo. Leak detection: run each task 10,000 times in one session and watch the heap. GC pressure: how many GC cycles does each backend trigger? An extension that causes fewer allocations means fewer pauses in long-running R sessions even when individual call times look fast. GC pressure is the dimension where Zig's explicit allocator model has the most interesting potential. You can choose not to heap-allocate for many operations. Whether that translates into measurably lower GC interruption in practice is something the harness will measure directly.

Safety. Crash resilience: what happens when an extension receives a NULL SEXP, the wrong SEXPTYPE, a vector with the wrong length, or a negative index? Does the R process crash or recover cleanly? Error propagation: does the extension call Rf_error() and signal a proper R condition, or does it abort()? R should survive an extension error without losing the session. PROTECT audit: static analysis using rchk, which specifically checks PROTECT/UNPROTECT discipline in R extension C code. Passing rchk clean is harder than it looks, and most extensions do not.

Developer experience. Compile time from a clean build. Binary size of the resulting .so. Both matter for CRAN distribution where size limits are real and cold installs happen regularly. Cold start: dyn.load() time in a fresh R session. Long-run stability: p99 latency over 50,000 task repetitions to surface latency spikes from background GC or OS scheduler interference.

The goal is a full four-axis comparison matrix: speed, memory, safety, and developer cost. The harness goes public when that picture is complete enough to be useful.

Design Constraints

zigr is built around three explicit refusals.

It does not try to cover every SEXP type. The surface is narrow by intention: numeric vectors, matrices, dataframes, strings, and the foreign function call itself. That covers what appears in real scientific computing extensions. Everything else waits for a concrete use case.

It does not abstract the C ABI. The build artifact is a shared library that dyn.load() treats identically to a compiled C extension. The call goes from R through .Call directly into Zig. No generated wrapper layer. No code to read through when something goes wrong.

It does not grow for completeness. New types and operations go in when they are needed, not because coverage is a goal in itself. The narrow scope and the PROTECT safety guarantees are related: the smaller the surface, the more completely those guarantees can cover it.

Closing

A lightning rod does two things. It carries the strike, and it keeps the structure standing.

That is what zigr is trying to be for R extension work. Bring the speed. Stop the bugs from burning the house down.

The benchmark harness will go public alongside the library. v0.0.7 is a proof of concept. The numbers suggest the direction is right.