Fil-C: How it works and is it safe
There has been a storm raging over on X (okay, not a storm but certainly a kerkuffle) after Bun moved its entire codebase from Zig to Rust in just over a single week of agentic coding. Pretty impressive.
This port triggered arguments, debates and fights and name-calling over what is memory safety and how Rust falls short and how Zig sucks yada-yada. Kelley’s barbs but not barbs pointed towards Rust culminated in him waking up one fine day to declare that Zig might support a Fil-C like compilation mode in the near future as a way to once-in-for-all, solve the issue of memory safety (unlike Rust) in Zig.
Cue to Pizlo: the author of Fil-C, who has been doing demos, showing that C code can be memory safe while simultaneously taking potshots at Rust with demos of unsafe blocks executing pointer code that SegFault and of code with no unsafe blocks running into segfaults (which turned out be safe Rust code calling a library fn that was in turn calling an unsafe fn that crashed the program)
Anyway this got me into a multi-night rabbit hole of trying to understand how Fil-C works and what is the mechanism it uses to achieve memory safety. This post is not exhaustive: The Fil-C website has more explanations on how safety is also achieved in “normal” C code, inline assembly and syscalls. I highly recommened that you read the documentation on Fil-C’s website if you are interested.
Today we are going to walk through the following sections:
- How Fil-C implements agumentations to C pointers to provide runtime
memory-safety. - Limitations of this model
- The obvious comparison with Rust (and Zig)
- Conclusion
Fil-C
Pointers in C are addresses. Dereferencing a Pointer, reads (or writes) the value at a certain address. Incrementing a Pointer moves it to point to a different address. If int* p points to address 0x4ef4 and we add 1 to it, it points to address 0x4ef8, since an integer takes up 4 bytes of memory. p[i] access the value at address p + i * 4 and nothing stops anyone from incrementing i till it results in an kernel triggered segfault.
Fil-C is a compiler pass (plus addtional shared libs and runtime). It is implemented as an addtional LLVM pass that modifies the LLVM intermediate representation for C (and C++) to augment the ptr types in LLVM IR. Each ptr is augmented with a lower and an upper, lower and upper being the address range that this pointer can access (lower <= ptr < upper). When malloc is called with say, 10 bytes to allocate ptr = malloc(sizeof(char)*10), ptr is an address with lower = ptr and upper roughly being ptr+16 (Pizlo mentions that all allocations are rounded to 16-byte values with addresses also aligned on a 16-byte boundary). The upper and lower values are passed through registers that are NOT exposed to the user (in C), thus ensuring that the user’s code doesn’t get access to these registers or their values.
InvisiCaps
Pizlo calls the augmentation “InvisiCaps” (invisible capabilities), and the best way I’ve found to understand them is to use a struct to illustrate how they function.
Here’s our struct:
c struct S { char* y; // a plain pointer int * _Atomic ptr; // an atomic pointer (note the qualifier position!) int a; // not a pointer at all };
This struct has one field each of a kind: an atomic pointer, a regular pointer, and an integer.
When you allocate the struct in the heap via malloc (or create the struct as a local variable, it wasn’t clear to me if InvisiCaps also work for stack allocations) using struct S* s = malloc(sizeof(S)), the compiler (plus the Fil-C augmented stdlib) emits code to allocate the memory (+ padding so that total_alloc_size % 16 == 0) and returns the ptr to the struct with a corresponding lower and upper.
Initially lower and ptr point to the exact same value and if ptr is incremented or decremented, it ranges between lower and upper (thus limiting the access range of a pointer)

Now each allocation carries a header with upper followed by a slot to hold the aux allocation’s address. This aux helps us embed in-flight pointers into structs without breaking the struct API. Recall that pointers occupy exactly 8-bytes on a 64-bit platform, so if we return a lower + ptr, we would not be able embed a 16-byte pointer inside our struct.
Aux
In our struct each of the pointers are also in-flight pointers, otherwise we wouldn’t be able to make use of Fil-C’s pointer safety, but exactly how can we keep track of a pointer’s lower while still embedding it within a struct ?
This is where the aux comes into play. The aux points to an object that is exactly the same size as that of the struct. If a struct contains embedded pointers as fields and we initialize the pointer like say: s.y = malloc(10), we store the lower for y in the aux at the same offset as y in s. This works due to C’s struct padding logic. In a struct, the alignment of each field is defined by the largest field in the struct. Since our struct S has 2 pointer fields, all the fields are aligned on an 8-byte boundary, so in the memory layout for a value of type struct S, y would occupy bytes 0-7, ptr: bytes 8-15 and a bytes 16-19 (the last 4 bytes are padding)
This means if we have a struct S with embedded pointers and its aux, we can deterministically find the lower of ptr from the S’s aux. Fil-C thus creates a new aux allocation of 24 bytes and sets the lower of y in aux[0..7] when ptr is initialized. By default the values in the aux are NULL, thus ensure that unintialized pointers have a NULL lower which turns every access of that pointer into an invalid access.
The aux slot in the header of s points to this aux allocation.
Aux isn’t needed if the struct doesn’t contain any embedded pointers and is set to NULL if we don’t store any pointers in the struct’s payload.

This schema allows not just bounds checking on the s pointer (ops like s+56 or s-332) but also on the embedded pointers (s.y+420 etc)
Atomic Pointers
Atomic Pointers are bit tricky. In concurrent situations (when one or more threads are incremented/modifying a pointer simultaneously), there are no guarantees on the order of increments (or on the value either). But there are cases where we need to ensure that a pointer cannot be modified by competing threads resulting in an incorrect value. As an example assume that we have a shared queue and a pointer points to its head, but multiple threads can insert in front of the head, so we need to ensure that whenever we update this head* it points to a valid element and not +/- bytes beyond the element.
Fil-C treats Atomic pointers as boxes: the {lower, intval} is a 16-byte box aligned on 16-byte address and the aux entry in the struct stores a pointer to this. The intval IS stored in the Struct’s payload, but only for the sake of conforming to the C struct ABI (so that stuff like memcpy works). All access to the atomic pointer is done through the aux -> atomic box
When the atomic pointer is incremented, decremented or swapped, instead of updating the intval in the box, a new box is allocated and the box ptr in the aux is updated to point to the new box with the old box being garbage collected later. The intval is then copied into the struct’s payload again (but this is purely for cosmetic purposes)

Limitations
From the Fil-C website it does look like Fil-C works (to a large extent). Pizlo’s recent talk shows a demo of him doing the presentation on a Fil-C compiled version of Libre Office running in what seems to be a Fil-C version of Linux with Fil-C vim and a Fil-C gcc (Clang). Pretty impressive again that one person has achieved so much.
Fil-C currently only works only on x86_64/Linux at the moment and is not supported on other platforms. I also wonder how it will work on macOS given that executables cannot be statically linked on macOS and it might not be possible for Fil-C to patch libc on macOS to get it work in all scenarios (I am no compiler infra expert maybe Pizlo has a solution for this). ARM64/Linux, maybe even Windows should be possible ? But as of July 2026 support is limited.
The first downside with Fil-C is that literally every piece of C code has to be recompiled with Fil-C to take advantage of these safety features, the reason being that Fil-C modifies every pointer to have bounds lower and upper and the generated code should include these bounds check for every pointer access. Fil-C introduces its own ABI and runtime and software has to be recompiled with a Fil-C patched compiler to take advantage of Fil-C’s features.
This will be an issue when Fil-C compiled software loads dynamic libraries (very common if you want to call into graphics driver code or other vendor provided software), the dynamic libraries might not support Fil-C thus voiding the safety provided by your Fil-C compiled code. As the Fil-C github repo shows, pretty much everything from the Compiler to the C lib and the applications must be patched and recompiled using Fil-C to make use of this new runtime safety net. Which brings us to the next and bigger downside: runtime performance.
All the bounds check, the automatic garbage collection of aux allocations, box ptrs etc do not come cheap. Pizlo himself mentions that runtime memory consumption go upto 2x of the non Fil-C and code can be upto 6x slower. This is almost JIT-ed NodeJS/Golang territory of performance regression.
If absolute performance isn’t an issue and you have to write software from scratch, even something like Go or Java seem like a much better choice. The type-systems are better (ok Go still sucks, but sucks less than C), there are much better package managers and you can find plenty of programmers for an affordable price who can maintain your systems
Comparisons with Zig and Rust
I do not intend to start a flamewar, infact the very motivation for writing this article is to show people the nuances and the tradeoffs. InvisiCaps seem like a nifty neat little idea but let’s compare it with the obvious comparisons: Zig and Rust.
Now I have written a few thousand lines of Zig as part of my effort to implement chibicc in Zig and my style of memory management was allocating big chunks of memory and then cleaning up them with one big swoop just before main exits (which basically means that I didn’t manage memory at all). Managing memory manually is hard, even in Zig with its defer statements. The core language is still unstable, with every minor version introducing a ton of breaking changes and radical redesigns thus forcing a lot of codebase churn. But the even bigger issue is that I don’t think Zig is a major improvement over C for compile-time memory management.
Here is the example of Zig code that allows Use After Free, where a pointer is free-ed but a reference to the free’d pointer is returned and the runtime segfaults. The program compiles successfully with the user being totally oblivious to the fact.

append_users_to_list allocates a User but also sets up a defer that frees the User right before the fn returns, thus leading to a Segfault when main attempts to iterate over the Intrusive Linked List.
But Govind, this is PEBKAC. You used a language incorrectly and now you are blaming the language for your mistake. Fair enough, but what if I told you there exists a language where this is caught at compile-time thanks to a more modern type-system ?

Here the compiler catches the fact that user1 doesn’t live long enough and refuses to compile. Not only do I get memory management for free, thanks to the fact that Rust frees up objects once they go out of scope, the borrow checker also ensures that I don’t end up with data races or dangling references at compile-time.
As an other example, here is the fact that Rust makes it an error to use a mutable static without an unsafe block, to drive home the fact that a STATIC mut could change anytime and the standard rust guarantees about mutable and immutable references do not hold

But Govind, Rust has unsafe blocks. Don’t you see that it is unsafe to use unsafe blocks ?
unsafeis NOT an escape hatch. It is not a magical incantation that lets you opt-out of the borrow-checker for example.unsafeis a marker where you can work with Pointers and a few other potentially unchecked behaviour (UnsafeCell, MaybeUninit etc). Its a pragmatic choice taken to deal with the fact that Rust code will have to interact with pointers and memory managed by code written in other languages or shared libraries. For example, in Linux, there is no data-race safe way to modify the environment variables of a process shared with other threads and sub-proceses (unless the env var is overriden when creating a child-process). Should Rust simply disallow programs from reading env vars ? Or forbid loading shared libraries or calling into C code that allows data-races ?
Rust took the choice of using the type-system and concepts like Borrowing, Ownership etc to rule out certain classes of behaviour at Compile time. If we can prove that using these types in this way cannot cause a race at compile-time then we do not have to insert runtime checks.
Fil-C meanwhile takes the opposite (but still pragmatic) approach that a lot of C code has been written and is unlikely to be ported to another language so it might be better to just re-compile it with a runtime safety net that prevents most of the nasty behaviour but with the downside that it runs slower.
LLMs throw a gigantic curveball into these tradeoffs. The great Bun Rust port experiment, something that would have been unthinkable even a year ago in terms of manpower and cost, was done in a week (granted Jarred Summer using Fable to do the port has much different results than say you are I doing the same thing). It is not out of the equation to consider using a coding agent to step by step port a program from C to Rust, so given that this option exists does Fil-C even matter ? I don’t know, but I am indeed glad that options to do both exist today. Even if possible, I don’t think every line of C code is going to be re-written in Rust and code written in C will probably run longer than my lifetime.
What about Zig then ? I feel like Zig has an identity crisis. It’s type-system is better than C for sure. It has good C-interoperability. But is it a big step-up from C ? I don’t feel so. It still pretends that Strings are nothing but slices of bytes represented as []u8. The stdlib documentation is a mess. I/O as an interface is a good idea, but the way to use it feels like a nightmare. The stdlib and language standard breaks with almost every minor release, with no realistic plan towards a 1.0 release. The destination is unclear, fuzzy and reactionary. By the time 1.0 releases there is a good chance that the momentum built upto it would have been lost and it becomes yet another personal vanity project relinquished to the annals of history. I hope that isn’t the case but only time (and sensibility) will tell.
Conclusion
Fil-C and Rust take different approaches to memory safety. The former tries to enforce safety at runtime, the latter tries to prove that if your program follows a certain set of rules, memory-safety issues will not occur at runtime. Rust has a very steep learning curve, although in the age of AI, it should be easier to learn. Zig ? I learnt a lot about systems programming, ELFs, OS apis, linking etc by reading through the Zig source and as much as I am indebted to Kelley and team for Zig, the idea of a slightly better C no longer appeals to me. As programmers we have always leaned towards more and better technology to solve problems and I would consider newer type-systems and compiler designs to be worth the effort to learn so that the programs we write today are more durable and robust, while still staying fast, compared to the programs written a few decades ago. My personal opinion is that both Fil-C and Zig are crutches rather than any fundamental redesign and will remain so because the authors cannot see beyond what they have grown up with.