Why Zig over C for Systems Programming
mtsoukalos.eu
mtsoukalos.eu
Defer will also appear in C compilers soon, but personally I still think goto-error handling is clearer.
(Rust has an advantage for temporal memory safety, but this does apply to Zig.)
https://blog.codinghorror.com/falling-into-the-pit-of-succes...
These days I wonder what is harder: 1. problems of collective action or 2. communicating that reducing everything to collective action is the real issue.
Suppose, also, that they have decided to make the change incrementally. So a rewrite is not in the cards.
The advantage of sticking with C that I see is that nobody has to learn a new language, migrate to a new toolchain, anything like that. So it is definitely the smaller change. But the downside is that it may be more difficult to hold the line. For some indeterminate (possibly indefinite) period of time you have a mix of legacy code that's being allowed to stay on the old conventions, and new/migrated code that's supposed to be held to the new conventions. IME one solo developer can maintain that distinction, but a team working in a business environment, possibly with people cycling on and off, will struggle with it. You can try to bake the distinction into your tooling, but that's often easier said than done. Most tools expect one set of conventions per project.
The advantage of adopting Zig that I see is that you set a clear demarcation line. New files will be written in Zig. Migrated code gets ported to Zig. Everyone knows that Zig has one coding standard, C has another coding standard. They look different, so it's easy to keep track of which is which. They each have their own compiler flags and static analysis tools, so it's hopefully easier to separate the legacy rules from the new ones.
Now, I mostly do cruddy business applications so I haven't actually done this with C and Zig. But I have done it on the JVM, and IME whether "modernize legacy Java in place" or "modernize legacy Java by strangler vining over to Kotlin" works better depends heavily on what the legacy code in question actually looks like and how much you want to change.
Though I've noticed the same trend when reading a famous Rust primer for C++ developers. Like there are obviously so many good and valid arguments when comparing the two languages that would've gotten the point across. Yet the authors felt the need to push it one step further, where the comparison no longer seems fair. Like comparing Rust with C++17 when C++20 provides the same exact feature that Rust claims to provide.
gcc -fanalyze ...
clang -fsanitize=address ...
etcgithub.com/ityonemo/clr
As soon as you have dependencies you are dealing with however the authors decided to build things.
Part of why languages exist is just to get a new ecosystem.
At this point, you could fork C/C++, only remove features, give it a new name, a new package service, and maybe fork the standard library, and you could end up with something pretty competitive against Zig or Rust.
Yes, new languages try to create a new ecosystem. I think this is a bad thing - we already have too many of such - often somewhat isolated - language ecosystems. But I also do not think we need new ecosystems, when we can evolve the existing one. I have seen C evolve quite a bit over time, so the idea that we have to create new ecosystems to solve problems is wrong in my opinion. C evolves very slowly, which means that some things need more time, but I also think that it is generally a very good thing that it evolves slowly.
This is why new ecosystems starts. Not everyone is ok with the slow pace
But I think a fair comparison of new languages to C has to take modern C features and tooling into account.
But while you can have some/most of the benefits Zig has from modern C, I think an underrated benefit is the language culture. As a different language it was the opportunity for a reset, e.g. it's idiomatic Zig to pass allocators to functions, meaning every library in Zig will work this way.
You could do that in C, but none or very few libraries do that so whenever you reach out to third-party dependencies (which is not a lot, in C as in Zig) you have to fight against C's traditional patterns.
1. yeah you can do that, but we should really do some (most? all?) of the things you mention (as well as other safeguards) mandatory
2. your code might be alright, but what about your dependencies ?
I have my own object system with constructor methods that all return a status code so you don’t need exceptions, my own stdlib for containers, buffered I/O, networking, hashing, math, threading, async, etc.
C++ gives you all the features to effectively build your own language, and you can use first class tooling since C++ is widely supported. LLMs also make it much easier to build out and test an entire stdlib on your own.
C++ is as much a tool for building nice systems DSLs as it is a systems language. The heavy investment in compile-time expressiveness has really paid off for the language over time.
[1]: https://github.com/microsoft/STL/blob/main/stl/src/vector_al...
I tried it again with C++ out of curiosity and C++ is ridiculously featured for making it into your own and little domain languages or something that feels like a new feature. Genuinely kind of awesome for transpilers and it definitely gave some insight to the "C++ is awesome, but you have to pick the 20% of features you like" wisdom I've heard
LLMs were fun for exploring C++ patterns as well. I could ask it techniques for approximating some construct in another language and get a full page of toys to try out. It definitely made me want to explore templates in my own language in addition to runtime generics
Sometimes I want to think about algorithms and memory management separately.
scores = {"alice": 100, "bob": 85}
scores["charlie"] = 92
Try translating to zig.This is why we need natural language -> pseudo code -> GC typed -> Low level systems.
Both Zig and Rust are fine as the last stage of this software development pipeline with different trade-offs. Problems arise when people try to use them earlier in the process.
By expressivity I don't mean what characters you use in the conrecte syntax, but the number of lines needed to express an algorithm (up to a small constant). C doesn't have C++'s expressivity because it lacks templates, and while you can sort of express that kind of thing with macros, it's not all the way there. Zig, however, gets you all the way there (you might need to write matrix_plus instead of +, but the expressivity is the same).
Which seems to be something Zig can't do.
Hell, you can define operators for generic length SIMD lane.
Can I write my SIMD/number abstraction and have it work?
Let's say I need rotors. With six dimensions. Can I overload it's basic numeric operations?
A more humble critique would be to just acknowledge that you don’t seem to be a member of Zig’s target audience, and move on.
Saying "you don't want operator overloading" in such cases is a reductionist view of how systems programming should look.
The point is, it’s a tradeoff. With overloading, exactly which code executes on a specific line becomes a bit more implicit because it depends on extra information from the surrounding context: the types of the arguments.
That comes with the usual tradeoffs. On the one hand, you could argue that the compiler is taking care of it for you, which saves the programmer from having to manually deal with it, and removes some visual clutter from the code.
But you can also argue that letting the choice of function be implicit means that a programmer who wants/needs to keep track of exactly what every line of code is doing needs to carry all that contextual information in their own head, too. For this programmer, it’s adding to the mental burden.
I don’t think it’s a coincidence that Kelley constantly brings up how Zig was inspired by difficulties writing a digital audio workstation with very hard real-time requirements. Part of what he’s doing there is giving a motivating case for why he wants to be an absolute control freak about, e.g., micro-managing exactly when memory is allocated and deallocated. That’s something only a subset of systems programmers need, because not all systems programming is dealing with things like hard real-time.
I actually don't like Rust's choice to implement + for Strings but most people seem to accept that, however C++ goes ludicrously far beyond "I don't like it but some people do" and straight off the cliff into "Nobody thinks that's OK" leaving you to wonder why there was effort to implement this bad idea. For example C++ can overload the short-circuiting boolean operators... but the overloaded versions don't short circuit. So now you've made things dramatically worse!
This depends on how you're squinting at this word "resolution".
Remember on a modern CPU an XOR R1 R1 does not result in an ALU operation, the decoder understands this idiom and just renames a zero'd register as R1, whereas of course it can't do that for say XOR R2 R3 because that's an actual XOR operation. So if "resolution" means "exactly what happens on the CPU" apparently there is no such facility provided even in assembler.
So apparently we mean only that it should have, in some sense, the same effect. I think the fact Rust is OK with
assert!("Goose".contains('o'))
assert!("Goose".contains("Goo"))
assert!("Goose".contains(&['a', 'e', 'i', 'o', 'u']))
.. meets this criterion, whereas presumably you do not agree.For a somewhat different example of the same general idea, look at how Java handles primitives and their boxed counterparts. = on two ints always compares their value. = on two Integers compares their pointers. But it uses flyweight instances for values up to 256, so in Java, whether two Integers with the same numeric value are equal according to the = operator depends on what that value is.
Then throw autoboxing and generics into the mix, and it suddenly becomes very easy to do refactorings that work fine in the unit tests (which tend to use small numbers) but fails in confusing ways in production.
This isn’t an example of operator overloading, of course. But I hope it more simply illustrates why, at least in certain problem domains, there’s value in being able to visually tell when surprising runtime shenanigans are and are not possible. And also to make it impossible to cross that boundary without touching all code that falls within the blast radius of that change.
I believe Java caches Integer objects for values -128..+128, not 256. But that may be an implementation detail, and not something to count on.
I'm looking forward to structured concurrency in Java 28. I already thought of a title for my article "Java Concurrency - Past, Present, and Futures".
We both misremembered. I found where this is documented and the range is -128..127 inclusive: <https://docs.oracle.com/en/java/javase/27/docs/api/java.base...>
OCaml basically gives you operator overloading this way without actually doing overloading. I'm wondering why we can't have these OCaml-isms in other languages - without needing to use OCaml - which sadly comes with other commitments.
Rust just stabilized its `Allocator` API in version 1.100.0. Definitely worth checking out!
Seems like it will be useful for use-cases like embedded where you're restricted to certain parts of the library anyway. But the battle is kind of already lost for support at the level of Zig or Odin. Even if they retrofitted support for the whole standard library, they'll never be able retrofit all crates or make people even bother adding support for all new crates.
The stdlib types that allocate have been generic (with default generic parameters) over the underlying Allocator used for a very long time. All widely-used data structure libraries where it's feasible/reasonable to use other allocators also are already generic over the Allocator.
It won't "take time to retrofit" because the language and ecosystem have already been prepared for it for 10 years. For context, async syntax was introduced a few years after that and if you just started learning the language today you wouldn't notice it wasn't there in 1.0.
> This release brings about the stabilization of the core parts of allocator_api, enabling library authors to define custom allocators that can be used together with certain standard library types - currently Vec and Box. These are enabled through the newly-stabilized Allocator trait.
That made it sound like it was rather limited currently.
I asked an LLM (because it's outside my expertise) and it said:
> It is fair to say that the heaviest architectural lifting is already done: adding a defaulted type parameter (A: Allocator = Global) preserves backward compatibility for existing code, and the core data structures already store and thread the allocator internally. However, plumbing that generic parameter through String, HashMap's std wrapper, and hundreds of standard library methods still represents substantial retrofitting work rather than a completed state.
Looks like The patch notes lead me a bit astray. But it's also not at an 'already all there' state either.
If you want to use your MyCustomAllocator with a HashMap or a BinaryHeap or you've lost your mind and want a LinkedList none of that was stabilized yet.
Personally I am slightly interested in HashMap, VecDeque and String but I think Vec is much more heavily used than any of those and will illustrate just how thin the pickings are in this space for real.
If there's a particular 3rd party collection you know would benefit, you could ask them, or write a PR to make use of Allocator. The standard library collections of course know people are interested, you can think of this as an MVP.
I just picked a few I personally want to see.
Odin in particular does a simply terrible job here. Take the most common need for allocations, a growable array type, in Rust Vec<T> and in Odin dynamic[T]
In Rust we only pay extra to store the allocator metadata if there is allocator metadata. If we're content using the general purpose allocator Global with global metadata it's free. In Odin we always need to pay to store an "allocator proc" function pointer and a (possibly unused) metadata pointer, so that's 2x machine word worse for Odin. We pay this even if we never allocate, and indeed even if we can't allocate, whereas of course in Rust that's free.
But then when we do need to allocate, in Rust that's a static dispatch unless our allocator specifically wants dynamic dispatch. So again for Global it's just a static dispatch. But in Odin it's always a slower dynamic dispatch.
The upside? To the extent there is one it's probably the New Jersey style, ie it was simpler to implement Odin. Which is a benefit to Ginger Bill, but not to you as an Odin programmer.
I have no practical Odin experience, I just looked at the core concepts. I tried a bit of Zig and there's significant discipline in the standard library to enable allocator use. But in 3rd Party libraries this discipline is hard to enforce because it also makes it harder to design easy to use interfaces.
I'm interested where on the spectrum of allocator usability Rust will end up at. I'd expect it somewhere between Zig and C++
struct user *user_new(const char *name, size_t n_groups)
{
struct user *u = malloc(sizeof *u);
if (u == NULL)
goto fail;
u->name = strdup(name);
if (u->name == NULL)
goto fail_user;
u->groups = calloc(n_groups, sizeof *u->groups);
if (u->groups == NULL)
goto fail_name;
return u;
fail_name:
free(u->name);
fail_user:
free(u);
fail:
return NULL;
}
I think you can represent C better than this. I like to focus on one cleanup block, and exploit the fact that free(NULL) is a no-op. struct user *user_new(const char *name, size_t n_groups)
{
struct user *u = calloc(sizeof *u, 1);
if (u == NULL)
goto fail;
u->name = strdup(name);
if (u->name == NULL)
goto fail;
u->groups = calloc(n_groups, sizeof *u->groups);
if (u->groups == NULL)
goto fail;
return u;
fail:
if (u)
{
free(u->name);
free(u->groups);
}
free(u);
return NULL;
}
Although note that this assumes calloc(sizeof *u, 1) initializes pointers to NULL, IIRC assuming the bit pattern of NULL is all zeroes is technically against the standard but every system you're likely to target will still work.If you follow the pattern of "it's OK to free NULL pointers", you can also re-use a free function:
struct user *user_new(const char *name, size_t n_groups)
{
struct user *u = calloc(sizeof *u, 1);
if (u == NULL)
goto fail;
u->name = strdup(name);
if (u->name == NULL)
goto fail;
u->groups = calloc(n_groups, sizeof *u->groups);
if (u->groups == NULL)
goto fail;
return u;
fail:
user_free(u);
return NULL;
}
void user_free(struct user *u)
{
if (u)
{
free(u->name);
free(u->groups);
}
free(u);
}
Then user_free() can also be a user-facing API function for cleanup, used elsewhere, and it can be written to be cool with tearing down partially initialized structures.If I want specific instructions to be run in a specific order, like touching hardware registers, I reach for intrinsics or assembly. If I want an array summed, I prefer to express the intent: total = the_array.sum(); I leave the implementation to the compiler / stdlib.
var i: usize = 0;
while (i <= n) : (i += 1) {
total += a[i];
}
That said, perhaps Odin, for which simplicity and readability is guiding principle in its design, is more up your alley. In Odin, can be read: for i := 0; i <= n; i += 1 {
total += a[i]
}
Exactly same style can be used in Go & V (which is/was? quite infamous here in HN). But in all languages, for this example at least, a foreach-like iteration, i.e. for x in a {
total += x
}
would be preferred.it is, in fact, better
C is in some ways minimal, but the pointer-syntax and some more I absolutely dislike. I also found C++ easier to use in some areas, such as:
cout << "Hey there" << endl;
The equivalent C is simply uglier. Of course C++ failed in other areas big time, but for such simple things like above, I think C++ is better than C. Naturally that cout example is very simplistic, but one can find other examples where C is really quite ugly.The ability to vomit out half-baked fragile code at record speeds via plagiarism factories of unprecedented scale with little mental overhead has swung the pendulum so hard in the opposite direction of intellectualism and the human mind. This is why people are depressed about AI. It's a revolution toward soullessness.
Check out Nimony effectively Nim 3.0 from the author. It's a top to bottom rethinking of Nim with all the lessons learned. I'm so hyped about it tbh
I don't mean that as an insult to Zig. Zig is amazing. But the ergonomics of Nim are just unbeatable.
This. If we continue on this LLM/AI trajectory, and you're not constrained by your platform to use a specific language, why would you choose C or Zig or C++ over Rust? You won't be reading or writing the code yourself, LLMs will, and they benefit from a strong type system that catches a lot of issues, meaning fewer tests are needed.
But the more common case is simply that each line of code gets some limited "thinking", whether it's inside the model's weights, a thinking block, or a whole turn. Only repeated turns can reason for arbitrary depths, so it's just not necessarily a good use of any of these buckets to spend on something we have a good and efficient algorithm for. Plus any of the former are stochastic which may accidentally ignore true issues (just like humans).
Poor AI
/s
No /s
We did?
I think Rust is doing much better on both fronts. It has always been a more pragmatic language than Scala, and the way it is developed supports that, rather than pushing the weirdness budget higher. The overall political lean of the Rust community is also more amenable to support in mainstream institutions + companies.
It's okay for me to declare a macro with `__attribute__((warn_unused_result))` for GCC and Clang, and `[[nodiscard]]` when the C code is later compiled with C++ (or nothing).
I was debating between C and Zig, but I still don't really know what I want to choose. Feels like C is probably the right choice given the languages age and it could technically be more useful (not that I'd be looking for a job doing C work).
For those that know both, what would you pick?
This has the benefit of you learning about manual memory management and how to deal with all the issues that come with such a rules free language like C that will basically let you do anything. This understanding means lot of the constraints and features that safer languages like Zig and Rust add start to make a lot more sense. For example, going from Python to Rust, the borrow checker will probably seem rather arbitrary, but going from C to Rust, you will find the borrow checker is just automating a lot of checks you were already doing yourself.
TLDR: Learn C. I would recommend the 15122 CMU course as a pretty good foundation. You can find it online here https://www.cs.cmu.edu/~15122/schedule.shtml .
The lack of a native slice is the most glaring C fault I think exists
(And of course the committees won't implement it, and of course C is going to remain broken)
But I am also quite happy about how far one already gets with variably modified types and a custom span type: https://godbolt.org/z/rPen5Movd
This is a hallucination. I'm sorry. This is what we call these kind of statements these days. Unless you can prove your humanhood, that's how it is now.
I'm not ideologically against AI, but I'd certainly not buy a book from someone who needs to use AI to write an article like that...
[0]: https://www.mtsoukalos.eu/2026/04/why-zig-over-rust-for-syst...
https://www.pangram.com/history/5f3d749c-713c-447a-82cc-d3a6...
Meanwhile no one's pointing out any technical errors.
I'm going to keep using C.
Its in my AGENTS.md file.
https://dennisforbes.ca/blog/microblog/2026/10/the_accidenta...
While I did just dismiss it as an edge issue, it turns out that this same rather clumsy sort of mistake has happened repeatedly in Zig's pure rewrite of MUSL. And despite being an absolutely catastrophically bad problem, by my judgment, the problem is still in main at this moment, across a wide number of musl function rewrites.
Which gives me pause about the whole zig project. If you're rewriting a critical library, making such simple but consequential mistakes shouldn't happen, and if they do happen it should be a red alert type situation.
The whole zig project is like six people in a trenchcoat. You can't expect them to prioritize your one thing after 3 weeks
Did you make a PR?
But I mean, the six people in a trenchcoat comment is basically precisely what should give people pause. In my project I simply eliminated zig from the stack entirely because even if I worked around the problems, what comes next? What's the next showstopper that gets waved away as oh well six people in a trenchcoat...
Eh, whatever.
https://ziglang.org/documentation/master/std/#std.mem.findSe... didn't exist then, now it does.
And if i may quote from https://ziglang.org/download/0.17.0/release-notes.html:
> Zig has known bugs, miscompilations, and regressions.
> Even with Zig 0.17.x, working on a non-trivial project using Zig may require participating in the development process.
> When Zig reaches 1.0.0, Tier 1 support will gain a bug policy as an additional requirement.
If you're not willing to participate in the development process, then you should wait or use something else.
Some people (not its creators) talk about Zig as if it's anywhere near production ready. It's fine to be excited about it (there's a lot to be excited about), but we gotta be realistic.
Now it's "FUD", also it's a beta and you're a beta tester, and it's 0.x, and look they disclaim that you shouldn't trust it, and barely anyone works on it, and... By the advocates for the language, it should be noted.
This is a bizarre thread. These very comments are precisely why it should give pause (which isn't remotely "FUD", and what an utterly asinine claim). Zig looks neat and it might get there, but this absurd apologism and goalpost moving is not convincing to adopt the language.
Again, in a submission advocating using Zig for system programming.
llm_nerd: "I found a bug. This gives me pause about the whole zig project"
I don't see how the latter is a fair reaction given the former. Especially since they only recently started working on the libc port (although it had been talked about for much longer)
Zig hides behind its pre 1.0 status to justify poor decision making while simultaneously actively trying to attract attention through marketing. That attention translates to resources and influence that are undeserved on the technical merits.
Alright then go for it, hotshot. We're waiting.
Always happy to learn.
https://codeberg.org/ziglang/zig/issues/37402
Looks like it is being worked on.
Personally I think it's not that catastrophic for zig < 1.0. Just a performance regression, and one can always link to other libc implementations, or is their own implementation enforced now?
https://ziglang.org/download/0.17.0/release-notes.html
> Zig has known bugs, miscompilations, and regressions.
> Even with Zig 0.17.x, working on a non-trivial project using Zig may require participating in the development process.
> When Zig reaches 1.0.0, Tier 1 support will gain a bug policy as an additional requirement.
I did not say it's acceptable, just not catastrophic, especially since workarounds exist.
I've used zig in similar capacity in production in the past and personally I would take the slowdown just to avoid dealing with MSVC :)
> I made the judgement call to let this slide in the review. I'm still not sure whether it was the right or wrong call.
https://lobste.rs/s/lh1j0e/shlemiel_painter_is_back_how_zig_...
It is what it is, but can work on what it already has, while keeping its tradition. Small changes, add common extensions, conservative additions, improve coherence and correctness of the standard while keeping easy to implement and small.
No crazy things.
Keep in mind that it is standardized and targets things others do not target.
Has this aged well though?
The TechBros pour in tons of money into "agentic programming" or "agentic workflows" right now. You can see that all over the place; OmarchySlop and other AI-controlled projects, where Claude is now the main designer. So in such a scenario, where does Zig really fit in? The "but for new code I choose Zig" is not a really compelling argument IMO. It reminds me of the issues that D has ("we are the better C++!!!" or even Java too). It's interesting that so many languages try to replace C. All failed, IMO. And C is still a pretty bad language. For instance, I think almost everyone would concede that crates in Rust are useful or good; C++ kind of submitted to this and has their own variant now as far as I know. C does not - or perhaps it has but almost nobody would know it.
I kind of want a language that really sits like in both camps: compiled, and scripting use. But each language seems to just fail hard in one area. Including syntax-wise. Complexity-wise too; I find Rust annoyingly complicated. Go is in many ways simpler than Rust, but it sucks in other ways. Of course there are always trade-offs either way, but I kind of want a language that can offer insane speed while having a great syntax (crystal also fails here; it is fast but the syntax is pretty bad. Why is "alias xyz abc" not working? It's so easy to support. But anyway I could go on for years here.)
And of course it needs to get to stable first