VolatileThunk

A Review of Odin - and, More Generally, of 2020s Systems Programming Languages

Tags: odin, systems-programming, systems-languages, plt, programming-languages, rust, zig, ziglang

If a software engineer wishes to do low-level, systems programming in 2026, which language are they to choose? Wielding direct control of a program with little help from a runtime - and especially no help with managing memory from a garbage collector - necessitates careful technology choices. Many technical decisions are obscured by higher-level languages, such as memory allocation strategies, handling non-blocking system calls, keeping track of variables captured by closures, providing runtime reflection, and a whole lot more. A lower-level language brings such decisions to the surface.

C defeated earlier Pascal variants to become the systems programming lingua franca for decades. Its semantics, its standardised "abstract machine model", and its hardware assumptions dating back to the PDP-11 have influenced systems programming throughout the establishment of software engineering as a discipline. Its syntax has woven itself into its syntactical descendants: C++, Java, JavaScript, et al. Assignment via the equality operator (=), curly braces, and modifier keywords reign supreme.

An example of C code:

#include <stdio.h>

int main(void)
/* Curly braces to denote blocks of code. */
{
    /* Type first in variable declaration. */
    volatile static int x = 0;
    /* Modifying a declaration needs a proliferation of reserved modifier
      words, like `volatile`. */

    printf("Assignment as expression! %d\n", x = 42);

    return 0;
}

C's domination of its niche proved that momentum of a language tends to trump concerns about its technical flaws - of which there are many in C. C's use of a textual preprocessor with no understanding of the rest of the language's grammar is a particularly poor accumulation of technical debt paid by the whole industry, especially when it stood in the place of a proper module system. C also made the incorrect choice about strings, choosing to delimit them with null terminators rather than tracking their length as Pascal did; this poor engineering choice incurred a cost to all other languages that had to interoperate with systems written in C. Responses to the language's shortcomings, of which those two examples are just two of a vast number, spawned a cottage industry of new languages positioning themselves as replacements: C++ and later Rust, Zig, Odin, and many other contenders.

I will be doing an intial review of one of the newer ones, specifically Odin. Although the review will touch on design decisions made by other languages in the modern systems programming language space.

Odin was created by Bill Hall, a.k.a. Ginger Bill. He has a slew of interesting articles, some about programming language design and implementation - he also apparently has YouTube videos documenting the development of Odin and other areas too , if videos are your thing.

This will not be a review rooted in several months of writing large systems with it; someone has already written one better than I could. This is instead an initial review from lighter usage for personal projects, rooted in comparisons with other systems language with which I'm already familiar - all delivered with a programming language theory (PLT) slant, as per this website's tradition.

The Odin Language's Logo

The Language Archetype

By design, Odin is not a revolutionary language. It has no distinctive, novel feature that serves as a point of gravity at the centre of the language's design; it has nothing like Rust's borrow-checker or Zig's support for ubiquitous compile-time programming. It appears as a spiritual Pascal descendant, fused with modern C-like assumptions such as curly braces and supporting early returns, modernised with a smorgasbord of incremental quality-of-life improvements over older systems languages: discriminated union types; length-tracked, native Unicode strings (specifically UTF-8); obvious, semantically-rich error-propagation; basic type parameters; a context system to easily allow switching allocators and loggers; and an acknowledgement of parallelisable hardware via SIMD types and native matrices. It adds such niceties while disavowing heavier features championed in the '90s: class-based object systems, built-in dynamic dispatch, powerful template-based meta-programming, and recoverable stack-unwinding error systems.

This makes it more unique that its parent "modern systems language" category would suggest. Its early embrace of non-global allocators and fallible allocation, and its laissez-faire attitude to (a lack of) memory safety, puts it into a distinct category away from Rust. Its relatively small feature set, and especially its steer aware from fully generalised abstractions and its rejection of object-oriented programming, marks it in different territory as C++. Its focus on a set of coherent, distinct features rather than a unified, semantically elegant language model contrast with Zig's approach; and so does its strategy of soon releasing just a language with a specification rather than waiting to finish a whole vertically integrated compiler toolchain.

C3 and the as-of-yet-not-publicly-released Jai seem to be its closest spiritual competitors. Go and D aren't considered in the same systems-language category due to their heavier runtimes including garbage collectors - perhaps unfairly.

Wirthian Heritage

C became an essential language for systems programmers, from early-day Unix engineering, to development with the Windows API in the late '90s, to macOS kernel-extension building throughout the '00s (albeit with Objective C's added object layer) - and everything in between, a list too large to enumerate. As such, its syntax was widely planted into the minds of systems engineers. Any new language entering the space without being a C derivative - or without at least copying its surface syntax - would create an immediate cognitive gap for would-be adopters. If a language wanted to fix semantic problems with C, why not smuggle such mental shifts under the surface-level familiarity of a C-like syntax? Even non-systems languages like Java took that approach:

"We were not out to win over the Lisp programmers; we were after the C++ programmers. We managed to drag a lot of them about halfway to Lisp."

-- Guy Richie, about Java's specification coauthor, discussing the language.

This dynamic led to C being a syntactic ancestor of many newer languages, of both systems and non-systems languages. Examples include C++, Java, JavaScript, C#, and D. Yet this led to a proliferation of C's syntactic quirks, some arguably mistakes: types before variable names, complicating type inference; assignment looking too similar to equality checks; a proliferation of modifier keywords; a lack of self-descriptive syntax for newcomers à la Pascal; a lack of syntactical encouragement of homoiconicity, present in Lisp and Forth; and often a dependency of symbol table look-ups to disambiguate certain tokens.

Niklaus Wirth's Pascal was too slow to fix crucial early flaws, contributing to it ceding the systems language mantle to C. But the syntactical parts of the language were, albeit subjectively, better designed than C: a more consistent grammar (often parseable in one-pass without needing a symbol table), more self-descriptive keywords, a real module system rather than a contextless preprocessing step blindly splicing files into other files, less ambiguity between assignments and equality checks, and a more disciplined distinction between expressions and statements - especially where it mattered, such as disallowing assignments in if conditions. Some of its decisions only look better in retrospect, such as types coming after variable names; that same decision in other later languages simplified type inference.

Odin appears to take its fundamental syntax from Wirth's languages, but peppers in enough C garnish to make it look "contemporary". Curly braces replacing begin and end is the key one; types after variable names are another. = is used for assignments again, but the language upholding a Pascalesque syntactic wall between expressions and statements disarms C's footguns in that area. proc looks like a nod to Pascal's procedure, and using ^ for dereferencing pointers rather than * is another smoking gun. Odin's package system soundly rejects C's philosophy in packaging, resembling a rather different lineage.

Other modern languages also borrowed Wirthian traits, as evidenced by Go shifting types after variable names and using := for type inference. Asking how much a programming language got directly from a source language, as opposed to indirectly via a more modern intermediate, is as much a rabbit hole as tracing English words' etymology to Latin and deducing whether Old French was an intermediate hop. For constructed languages like programming languages, only the languages' creators know for sure.

Let's just say it looks like Pascal with a strong C twang:

package main

import "core:fmt"

// C99/C++-style comments. Pascal-ish `proc`, but with curly braces.
main :: proc() {
    fmt.println("Hello, world!")
    
    // Variable declaration decidedly non-C-like.
    x: int = 42
    // `:=` looks Pascal-ish, but actually different: type inference in
    // declaration, not assignment.
    y := &x

    // Pascal-style pointer indirection.
    fmt.printf("%d + %d = %d\n", x, y^, x + y^)
}

The Borrow-Checker - Is Memory-Safety Mandatory in New Systems Languages?

No. Although it really depends on one's definition of "systems language" and the target domain they refer to.

Memory safety is the elephant in the room present in any room discussing the design of new systems languages. Specifically temporal and spatial memory safety. Four strategies have emerged for system languages:

Odin chooses #4, although it can also feasibly tap into #2 just like any other language. This is not an objectively incorrect decision: messy performance hacks often assume the ability to blur ownership of memory in ways that'd make Rust balk; and some arena allocators, memory recycling schemes, and batch-memory-freeing tricks directly work against Rust's model. Odin's language features make getting memory management right a lot easier than C, especially Odin's defer feature.

Yet this choice makes the language unsuitable for domains that wish to rule out spatial and temporal memory bugs entirely outside of explicitly delineated code fragments. Google Chrome's Rule of Two illustrates this well, and is evidenced by their refusal to ship a JPEG-XL parser until it was implemented in a language that can prove memory safety for new code.

package main

import "core:fmt"

main :: proc() {
    // Dynamically allocate memory the size of one `int`. Zero by default, not
    // a junk value.
    n := new(int)
    fmt.printf("Allocated, uninitialised `int`: %d\n", n^)
    n^ = 42  // Assign 42 to the dynamic memory via the returned pointer.
    fmt.printf("Allocated `int`: %d\n", n^)

    m := make(map[string]int)
    defer delete(m)
    // Guarantees deletion at the end of the current scope, so long as the
    // program isn't "hard aborted" without the chance to clean up.
    
    for k, v in m { fmt.printf("Map: key %s, value %d\n", k, v) }
    
    // `n` was not freed, so it was leaked!
    // To free it, we should write `free(n)`. Or `defer free(n)` right
    // after declaring it.
    
    // `make/delete` allocate and deallocate built-in, "reference-like" types
    // such as maps and dynamic arrays. `new/free` allocate memory directly
    // according to the provided type's require storage. C++ engineers have a lot of
    // muscle memory to give up!
}

A Systems Language as an Unapologetic Macro-Assembler

At this point, it's informative to ask ourselves what we're actually using a "systems" language for: a high-performance, complex language of towering abstractions? Or a macro-assembler with just enough features to achieve architecture portability and provide the fundamental building blocks for software abstractions? A macro-assembler that embraces low-level, unsafe details enough to bake a new embedded assembly variant directly inside their main language?

Do we wish to create a towering spire of heavily-compile-time abstractions dependent on a sufficiently smart compiler to turn them into highly performant code; but only if engineers can follow the language's baroque rules? Are we instead more inclined to use a semantically simple language whose performance optimisations must be more explicitly phrased, a language that pushes back on excessive abstractions, to increase localised clarity of how code is actually executing, and an embrace of unsafe-yet-direct manipulation of memory?

I'm not sure about you, but I actually want both for different problems. That's why I've been playing with Odin despite remaining content with Rust. Apologies for rolling out the hackneyed old Computer Science catchphrase: "there's no right answer, it's all about trade-offs".

Looking at these trade-offs from the perspective of AI, I suspect AI agents will lean towards Rust's model more: the additional complexity of writing Rust can be handled by code-authoring LLMs, while the semantic guarantees of resource lifecycle ownership create virtuous validation cycles for agents that are autonomously refining solutions towards a instructed end goal. Technology management like the idea of absolving entire categories of problems, e.g. memory-safety, by chucking more AI tokens at the problem, validating the absence of said problem via static tooling in the loop. I have a hunch that Odin's community will adopt LLMs less than Rust's, for that reason among many - and because Odin is a smaller, more independent community with fewer AI-acolyte commercial sponsors, of course.

"Zero is Initialisation" - Go's Model Strikes Again

C defaults to undefined (often junk) values for new variables without initial values. More specifically, whatever the implementation has left in the memory location before it was reused for a given variable. This C misfeature has, thankfully, been abandoned by almost all subsequent languages. Incredibly, this matter still seems up for debate in the C++ community.

The two sensible approaches adopted by non-C languages are:

Both are coherent approaches. The first approach incurs a cost to engineers and agents up-front: that initial values must be defined for all variables. However, there are no serious edge cases beyond that - although it's easier to pull off if a language is more expression-based than statement-based.

By contrast, the second approach removes the need for an initial value but creates more questions: namely, what is a "sensible default" value that works in all contexts? There isn't one. false might be a common-sense default value for an user_authenticated boolean field in a struct somewhere, but a problematic default for an is_found boolean used in a while loop - leaving an assignment out in one branch shouldn't cause an endless loop by default.

The effect for consumers of an API or a type is that they must anticipate default zero values and then attempt to predict whether such a zero value was set explicitly or was created by leaving a value out entirely - and whether leaving that value out was intentional. This becomes even more of an issue with composite types like Odin and Go's structs: is a field holding a zero value because the user wanted it to be "empty", or did they simply forget to set it because the struct field was added after they started consuming the struct in an earlier version of the codebase?

And what is the default zero value of a pointer in a language that has them? The much-maligned null (or nil), of course. Languages must also decide whether a default zero value for a dynamic map or dynamic array creates a null that raises errors on being used, or allocates an empty map or array that immediately accepts adding new values. Odin also accumulates complexity from the intersection between default zero states and discriminated unions, creating complexity like #no_nil.

As I'm signalling with little subtlety, I prefer the approach of mandated explicit values over default zero values. Yet Odin's choice of default zero values makes more sense when considering its named return values. When a return value can be read before returning from a function, it needs a value. Mandating explicit initial values for named return values would be unprecedented in any programming language I've heard of.

package main

import "core:fmt"

main :: proc() {
    m1: map[string]int
    m2 := make(map[string]int)
    
    // Both maps are conceptually "empty", with `m1` adopting the `nil` value
    // by default. But inserting a map handles such values for its underlying
    // map, doing allocations behind the scenes when necessary.
    m1["foo"] = 42
    m2["bar"] = 42
    
    m3: ^map[string]int
    // But using a `nil` _pointer_ causes a segfault on most systems.
    m3^ = m2
}

Multiple Return Values - Semantic Consistency, and Playing Nice with Inline Assembly

The ML dialects from functional programming land teach us that functions can get away with allowing just one parameter and one return value. Through function currying, multiple-parameter functions can be trivially created in a language that only allows one parameter per function, so long as the syntax is set up to make it feasible.

Most other languages embrace multiple parameter support for functions. Many go even further, allowing named arguments, optional arguments, and variadic arguments. For systems languages, supporting multiple parameters is a no-brainer: multiple arguments passed in just keep getting pushed onto the stack, get spread across multiple CPU registers upon the call, or some combination thereof depending on the calling convention.

If our systems languages support multiple parameters for functions, why not multiple results on the way out too, i.e. multiple return values?

Go and Odin agree. Both languages support returning multiple items, unlike C, C++, and Rust. Some will argue that returning a single composite is effectively equivalent, such as returning a destructured tuple in Rust. I don't agree; following that logic, we could arrive at Haskell's extreme by removing multiple input parameters, or argue that multiple arguments can just be provided via a single tuple.

The more interesting line of enquiry is about the use case overlap between multiple return values and returning a single, discriminated union - a.k.a. a "tagged union". Consider returning an error from a function: is it better to return a potentially-optional error as the final return value, or to return a single Result union that is either a value or an error? Both work. My personal preference is a union because the error case automatically rules out the successful result case, but multiple return values don't require as much type-system complexity and have ergonomic overlap with named return values in the calling function. In addition, doing an early return for the presence of a final error value among multiple has similar ergonomics to another feature: an early-return operator for error types as unions - or, more generally, monadic-style errors. Look how syntactically equivalent the two features are across Odin and Rust:

calling_function :: proc() -> (err: Error) {
    my_erroneous_function(42) or_return
    fmt.println("finished")
    return
}
fn calling_function() -> Result<(), Error> {
    my_erroneous_function(42)?;
    println!("finished");
    Ok(())
}

I don't see why the existence of proper unions has to preclude multiple return values, as some engineers state. While they have overlapping use cases, they seem like distinct features.

Odin also has another trick up its sleeve: a newly overhauled inline assembly feature, still in flux. The feature fits in smoothly with multiple return values:

divmod_u64 :: asm(n: u64, d: u64) -> (quo, rem: u64) [
	n -> quo = %rax,
	rem      = %rdx,
	#clobber %flags,
] {
	xor %rdx, %rdx
	div d
}

All-in-all, multiple return values make complete sense considering the rest of the language.

Fallible Allocations & Temporary Allocators

While comparing with Rust, let's look at another comparison:

build_1_to_3 :: proc() -> [dynamic]int {
    result: [dynamic]int
    append(&result, 1)
    append(&result, 2)
    append(&result, 3)
    return result
}
fn build_1_to_3() -> Vec<i32> {
    let mut result: Vec<i32> = Vec::new();
    result.push(1);
    result.push(2);
    result.push(3);
    result
}

(Rust doesn't need explicit returns at the end of functions, and the more convenient vec! macro form is omitted here for clarity's sake.)

This function creates a dynamic array, reallocates it three times to add an additional element each time, and finally returns the array. In C, reallocation like that is done with realloc, and reallocing an array in such a manner requires checking for NULLs from each realloc; after all, the reallocation could technically fail. Yet Odin and Rust blithely ignore this possible outcome. At least C++ throws a catchable exception for failed allocations with new. What can we do to handle such allocations elegantly? Let's start with Odin:

build_1_to_3 :: proc() -> (result: [dynamic]int, err: runtime.Allocator_Error) {
    append(&result, 1) or_return
    append(&result, 2) or_return
    append(&result, 3) or_return
    return
}

Notice that named return values allow the use of or_return to return an allocation error from each append if it fails, and the declaration of result becomes handled by the return type signature. This works because Odin allows adding optional error values that can be ignored. This lets the same API provide both a fallible and an infallible interface for the function, depending on preference. append and other built-in functions take advantage of this. It makes errors too easy to miss when attempting ubiquitous fallible allocation across a program, but it avoids the need to create parallel APIs for all fallible use cases.

Now let's try Rust:

fn build_1_to_3() -> Result<Vec<i32>> {
    let mut result: Vec<i32> = Vec::new();
    result.push(1)?;
    result.push(2)?;
    result.push(3)?;
    Ok(result)
}

Notice the question marks at the end; they signal an early return if push returns an allocation error. Yet there's a catch: push does not yield such an error, so that code will not compile. It pretends allocations always succeed. The fallible alternatives that exist are not as ergonomic and added quite recently (1.57.0), or they're "nightly", meaning a lack of guaranteed stability. When you dig deep into standard library APIs assuming fallible allocation, i.e. allocation methods that can return allocation errors, you realise that they're often missing for certain collection types and operations. Whereas almost all relevant functions in Odin have variants returning potential allocation errors.

"Pretending allocations always succeed" is actually an unfair characterisation. Such Rust methods will handle failed allocations by panicking, unwinding the whole stack and taking down the program if a supervisor of that thread does not recover from the panic. Panics can also be configured in some Rust builds to abort rather than unwinding the stack; in such cases, the caller is given no choice on the matter.

Odin's approach is somewhere between Rust's and Zig's. Zig only has fallible variants in the standard library in practice. Rust has infallible variants by default and has only recently started stabilising fallible versions via entirely separate methods. Odin has a small selection of reallocation functions for the built-in dynamic data structures, but most of them support both fallible and infallible versions. Core libraries built on top of them propagate this property. There was a recent regression on map_insert for built-in maps, but I've flagged it.

Which memory allocator is used for all of this, anyway? C uses the system allocator by default for malloc, realloc, calloc, and free. To use a different allocator, call different functions. This is clearly an unscalable solution for programs that routinely want to experiment with different allocators. Zig, Odin, and Rust all try to solve this problem better.

Zig has the most robust and complete - if verbose and noisy - solution: all allocations must take the allocator as an argument, and allocators get threaded through the callstack as parameters. Finicky and repetitive, yet consistent and predictable.

Rust's solution is a globally assigned allocator. This is only slightly better than C, and is clearly the worst solution of the newer, post-C systems languages. This is going to be solved by "allocator API", but this has been parked in development for over six years and - wait? It was finally released late last month? Brilliant! But even with this now implemented, the ecosystem will suffer a split between libraries that take custom allocators and those that always use the global allocator. Better late than never though.

Odin's solution is intriguing: a context struct that gets implicitly passed by address to every procedure by default, which carries various implied states such as the current allocator. All standard allocation APIs use the current context's allocator if not explicitly stated. This default being baked into the language and standard library early on effectively means that Odin and its surrounding ecosystem has strong support for custom allocators even for subsets of programs. In addition, Odin splits the standard allocator from the temporary allocator. This means a program can start with a system allocator, switch to a different allocator for testing code (free everything at one go at the end?), and let each frame in a game engine or each HTTP request use temporary arena-based allocators that free everything in batch at the end of the handling of the frame or the request. Frames, requests... whatever temporary duration suits your program's domain.

As an aside, domains in which Odin is used may actually opt into infallible allocation for a subset of a program where they know allocations "cannot fail", such as a temporary arena allocator used while allocating accumulated state changes during a single frame in a game. If they know the maximum number of state changes during a frame change is bounded below the area's maximum allocation, they know allocations cannot fail. Given this culture of widely supporting the overriding of allocators, and greatly diverging allocator behaviour, Odin supporting both fallible and infallible allocations looks sensible.

How would I assess these varying approaches? Zig's is the most disciplined and consistent, Odin's is the most ergonomic and flexible, and Rust is playing catch-up. However, the implicitness of fallible vs infallible invocations of Odin's allocator APIs, I suspect, will become a problem in larger Odin programs. I haven't yet found a clear way of instructing Odin to force always treating allocations as fallible and refusing to build with third party packages that don't make that assumption. I sense a possible library ecosystem split on this point - although that's alleviated by Odin having a large standard library and discouraging an ecosystem of many, small third party packages. In short, I reckon Zig takes the trophy despite its lack of ergonomics.

Generalisation ad Infinitum, or Simplicity via Constrained Abstractions?

Taking a step back, Odin clearly prefers adding ergonomic features to solve specific problems case-by-case, over layering ultra-generalised abstractions that cover every possible future case. This is by design. Interestingly, Rust's original creator also appears to lean slightly in this direction too in some specific areas.

A common mistake made by programming language aficionados is to assume more generalisable, abstract constructs are always better. Why shouldn't for loops work directly with any user-defined iterable type? Why shouldn't we be able to define any operator we like? Why shouldn't indexing operators work for user-defined container types? Why not give users "reader macros" to let them define their own literals syntax in source code? What objections could exist to letting users run almost arbitrary code at compile-time?

A lot of flak fired at Go in its early days were along these lines.

The truth is that - yes, I'll say the line - there are trade-offs. Can you immediately reason about the performance characteristics of your for loop when its implementation is yielding inside a closure capturing a bunch of enclosed variables? Was Scala's ecosystem of "ASCII-art operators" really a boon for the Scala ecosystem, or did it end up as a joke at its expense? Is operator overloading really needed, or do we just need to hard-code it to a small set of numeric types and vectors? What if someone does something crazy like overload bitshift operators to do I/O? Are we sure limitless syntactical extensibility will lead to an ever-growing ecosystem, or just cause endless fragmentation in the spirit of Common Lisp? Does forcing all compilers for a language to embed an interpreter for compile-time programming thwart the creation of new implementations, given the added complexity?

Returning to the view of Odin as a portable macro-assembler with some nice abstractions for scaling up software building, isn't there actually a tangible benefit to everyone using the same small set of good-enough data structures with the same hard-coded operators, whose performance characteristics are well understood?

package main

import "core:fmt"

main :: proc() {
    // Blessed syntax for generic, dynamic arrays - user-defined type
    // parameters exist, but using a different syntax.
    elements: [dynamic]int
    defer delete(elements)
    
    append(&elements, 42)  // Let's just assume reallocation worked...

    // Built-in composite types get element-lookup operators, your containers must
    // use procedure calls instead.
    elements[0] = 0
    
    fixed_elements: [3]int = { 1, 2, 3 }
    // Operators are "overloaded" - but only in fixed ways, e.g. for array programming.
    // You cannot overload them for your own types.
    fixed_elements *= 2
    for element in fixed_elements { fmt.printf("%d\n", element) }
}

I think Odin's approach makes sense for Odin. I'd want more generalisable abstractions in a high-level application language where I'm less precious about how abstractions are actually implemented - and about tracking their implementations' performance characteristics. Abstractions come with a price tag attached. In some contexts I'm willing to pay it, in others I'm not. I am convinced by both Odin's approach and by the approach on the opposite end of the spectrum: ultra-generalisable and uber-abstracted Rust. I am not convinced by Go trying to straddle both camps at once, and losing its identity in the process. Pick a lane.

Language Specifications versus De Facto Implementations

During the late twentieth century and throughout the '00s, authors of new programming languages had some well-trodden paths if their language were to become widespread. For general-purpose languages, here was one of them: first, ensure a working implementation. Then check the language's fundamental assumptions carry over to other environments, i.e. other operating systems and hardware, maybe on other university campuses. If the language gathers momentum off the back of that, pursue formalisation of the language, creating a specification. That'd allow for reliable re-implementation by other organisations for their own niches. With multiple organisations having vested interest in the language's success, committees can then be formed to incrementally improve the language's specification, producing future, revised specifications.

This approach has flaws, as anyone who has followed the bureaucracy of a standards committee will know. But it does theoretically end up with myriad implementations to shake out the poorest ideas, and ensures healthy competition between different implementations all matching the same baseline.

That was a low-resolution simplification and rationale of the process, yet C, C++, Fortran, Cobol, and Common Lisp all got formal standards through a timeline vaguely recognisable as the aforementioned. Even larger, more modern languages got this treatment: Java was standardised and then independently reimplemented by Apple, Microsoft, and others.

Portability came from distilling the portable common denominators between implementations of the same language, standardising the common ground, and then letting future engineer program to those standards to get the same, predictable behaviour between language implementations.

In the last decade or so, this blazed path has been increasingly ignored. Rust still only has one de facto implementation; the two GCC-adjacent alternatives are valiant efforts yet remain substantially behind. Zig has exactly one blessed implementation. Writing Python means writing to CPython in 2026. There are only two JVMs used outside of specialised contexts such as HFT: OpenJDK and the non-standard Android JRE that an infamous court case was fought over. Go has just one widely-used implementation, as does Kotlin. Ruby did get a standard but then saw no reason to keep it meaningfully up to date. JavaScript is the exception that defines the rule, having multiple implementations as a result of the browser-wars of the '00s and '10s, and getting well standardised as a result.

A single, de facto implementation allows a language's community to marshall their resources towards a single codebase. Language refinements can be shipped quickly, rather than waiting for every main implementation to finally get around to it. But such a lock-in kills competition; recall how long it took for CPython to even discuss adding JIT compilation or adding a "free-threading" mode. Or how rustc can race ahead with new features with little regard for other Rust implementations, leaving them continually playing catch-up while typical Rust developers code to just a single implementation, blissfully unaware. Compare with C, that has survived constant hardware architecture and operating system changes throughout decades without breaking the language - from even different interpretations of "byte", to the introduction of threads, to even C interpreters being made available. Those are the portability and durability benefits that afford even a language full of tricky edge cases, once it's willing to commit to a reproducible specification.

In an era where - due to the advent of high-quality LLMs - new passable implementations are now far cheaper to create than the good ideas that back them, forcing a single de facto language implementation seems like an increasingly short-term decision.

I believe that C, C++, Java, JavaScript, and Hare took the correct approach here; and that Perl, Python, Rust did not. Zig and Odin are both too young to yet judge on this point, but Odin's creator has emphasised the importance of a specification for Odin 1.0, a.k.a. Odin 2027. Unlike Zig's all-encompassing, vertically-integrated toolchain, Odin looks scoped tightly enough as a project - as just a language and little else - that it could actually feasibly fit in a reasonably-sized specification. Even if it is not a formally recognised one, one published confidently on the main website would suffice.

#!/usr/bin/env python3

print("Hello, world!")

# Safe in pure Python until...

import cryptography

# Well, let's hope all your users now either use _only_ the binary releases, or
# have a whole Rust compiler plus use CPython or PyPy specifically, or that
# import will not work. Also, forget about any non-CPython implementation of
# Python.
#
# This is what happens when an ecosystem targets a single de facto
# implementation rather than a language specification.
#
# https://cryptography.io/en/latest/installation/#building-cryptography-on-linux

Verdict: Yes, I Think Odin is a Spiritual Successor of C - and Why Rust, C++, and Zig are Not

Congratulations on reading so far through this missive. The previous sections, together, explain why I think Odin has one of the better shots at being a spiritual successor to C in the systems programming space, specifically in the classic "portable macro-assembler" role. To summarise, Odin:

Zig, Rust, and C++ all tick some of these boxes; but none of them tick all of them. That isn't to say they are worse languages - Rust remains one my go-to languages, probably one I will continue to use far more than Odin. I just don't think it will replace C in the "modern, simple portable macro-assembler" niche as well as Odin can.

Upcoming Challenges & Constructive Critiques

Could any language blockers derail Odin's stabilisation of 1.0, a.k.a. "Odin 2027", and its positioning as the spiritual successor to C?

I have some smaller, subjective objections about the language: why is do almost soft-deprecated with a dedicated disablement flag and discouragement in the style guide, yet retained in the language? Why are there two types of runtime ternary operator that do the same thing? Could the interplay between default nil for unions, #partial, and default empty cases in type-switches be made less confusing? Realistically, none of these are blockers for a release. The existing behaviour could be baked into 1.0 as-is without serious repercussions; they are nothing more than my own minor aesthetic niggles.

I suspect the more substantial issues that will be encountered are:

Looking to Odin 2027

As per Odin's 1.0 announcement, it is aiming for an initial stable release in 2027, appropriately called Odin 2027.

Odin fills a narrower market gap than a surface analysis would imply, and I believe it fulfils the "modern, portable macro-assembler" archtype better than other modern systems languages. Rust's and Zig's niches ought to be filled too, but genuinely different niches they are.

It not being entirely memory-safe is fine for the role it's taking on. Its conservative governance so far indicates it will avoid the scope-creep of Go, a language increasingly resembling a poor man's implementation of Rust with lesser implementations of type-parameterised methods and generic iterators.

In the systems programming space, it's cathartic to see some C-isms finally give way to better Wirthian language features - Pascal finally gets some comeuppance. Rust and Zig have also done excellent work by moving the systems language space away from some of the technical debt carried through the years by C.

Odin's lack of an expensive-to-implement, grand, unifying feature - universal compile-time programming, ubiquitous object-oriented programming, or a strict, static lifetime system - makes it pragmatic, and presumably allows for more approachable, alternative language implementations. This is reinforced by a lack of a reliance on a sufficiently smart compiler à la Rust, and a reduction in undefined behaviour. That, plus the push towards a specification, should leave fertile ground for a plethora of alternative implementations.

The explicit support for modern hardware features like SIMD and the focus on an improved inline assembly feature ensures "machine sympathy" over the architecture astronautism we've seen in the last couple of decades. Memory allocation strategies are brought to the forefront rather than abstracted away. For a spiritual successor to C, these properties are crucial.

Odin 2027 looks well-placed to achieve its stated goals - I look forward to seeing how it evolves throughout the next year!