Salam vs Zig: two young compiled languages for low-level software

Zig and Salam are both pre-1.0 systems languages with no garbage collector, a defer statement and a try keyword. They make very different choices about allocators, compile-time code and how much the standard library should do.

In this article
  1. Two young languages with a lot in common
  2. Error handling: error unions and throws
  3. Memory: explicit allocators vs defer and free
  4. comptime vs Salam's compile-time features
  5. C interop
  6. Cross-compilation and toolchains
  7. Performance
  8. Ecosystem and stability
  9. When to choose which
  10. Frequently asked questions

Salam vs Zig is a comparison between two young systems languages that look alike at first glance. Zig is a low-level language designed as a better C: no hidden control flow, no hidden allocations, explicit allocators passed to whatever needs memory, and comptime, which runs ordinary Zig code at compile time. Salam is a statically typed systems language with a block syntax closer to Python or Lua, a large application-level standard library and source that can be written in English or Persian. Both compile to native code, neither has a garbage collector, and both are still before their 1.0 release.

This article compares how they handle errors, memory, compile-time code, C libraries and cross-compilation, with the same small programs written in both. The Salam programs are compiled and run when this site is built. The Zig programs follow the 0.15 standard library, and since Zig is pre-1.0, details may change in later versions.

Two young languages with a lot in common

Zig first appeared in 2016, created by Andrew Kelley, and is developed by the Zig Software Foundation. It already has well-known users, among them the Bun JavaScript runtime, the TigerBeetle database and the Ghostty terminal. Salam is younger and its compiler has been written in Salam itself since the original C implementation was retired in August 2026. Both are open source.

ZigSalam
Design goalA simpler, safer replacement for CA readable systems language for apps, servers and low-level code
SyntaxC-like, braces and semicolonsBlocks with : and end, no semicolons, English or Persian keywords
MemoryExplicit allocators, passed as argumentsOne global allocator for collections; mem arenas and pools on request
ErrorsError unions (!T), try, catch, errdeferthrows, try, catch; errors carry a kind, a message and a position
Compile timecomptime: run any Zig code while compilingConstants, conditional compilation, -D flags, monomorphized generics
C interopImports C headers; zig cc compiles C and C++extern: declarations; calls follow the C ABI on every backend
Standard libraryLower level: data structures, I/O, HTTP, JSON, cryptoApplication level: HTTP router, databases, JSON, TLS, images, ML
StatusPre-1.0, breaking changes between releasesPre-1.0, breaking changes between releases

Error handling: error unions and throws

The two languages ended up with surprisingly similar error handling. Here is a function that parses a TCP port in each, and a loop that reports good and bad inputs. In Zig, a function that can fail returns an error union, try passes an error up, and if/else with captures handles both outcomes:

Zig
const std = @import("std");

fn parsePort(text: []const u8) !u16 {
    const n = try std.fmt.parseInt(u32, text, 10);
    if (n < 1 or n > 65535) return error.OutOfRange;
    return @intCast(n);
}

pub fn main() void {
    const inputs = [_][]const u8{ "8080", "70000", "http" };
    for (inputs) |text| {
        if (parsePort(text)) |port| {
            std.debug.print("port {d}\n", .{port});
        } else |err| {
            std.debug.print("bad: {s}\n", .{@errorName(err)});
        }
    }
}

In Salam, a function that can fail is marked throws, every call to it is marked try or sits inside a try block, and catch handles the error:

Salam
import conv

func parse port(text: str): int throws:
    n := try conv.Atoi(text)
    if n < 1 or n > 65535:
        throw Error.New(ErrorKind.OutOfRange, "port out of range: " + text)
    end
    ret n
end

func main:
    each text in ["8080", "70000", "http"]:
        try:
            println "port", parse port(text)
        catch e:
            println "bad:", e.message
        end
    end
end
Outputport 8080 bad: port out of range: 70000 bad: invalid character 'h'

The shape is the same: no exceptions, no stack unwinding, every failure point visible at the call site, and defer statements still run. Both also have a one-line fallback: Zig writes parsePort(text) catch 8080, Salam writes try parse port(text) catch 8080.

The difference is what an error carries. A Zig error is a name from an error set, and that is all; extra details travel through a separate diagnostics value when you need them, and Zig's error return traces show where an error came from in debug builds. A Salam error is a value of the built-in Error type with a kind, a message, an optional code and, for parsers, the line and column, so json: missing key 'age' (line 1, column 1) comes straight out of the standard library. Zig has errdefer, which runs only when the function returns an error; Salam has no equivalent and uses defer or an explicit catch.

Memory: explicit allocators vs defer and free

Allocators are Zig's signature feature. Any function or container that allocates takes an std.mem.Allocator, so the caller decides whether memory comes from a general-purpose heap, an arena, a fixed buffer or a testing allocator that reports leaks. Since Zig 0.15 the standard ArrayList does not even store its allocator; every call that may allocate receives it:

Zig
const std = @import("std");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var squares: std.ArrayList(usize) = .empty;
    defer squares.deinit(allocator);

    for (1..6) |i| {
        try squares.append(allocator, i * i);
    }
    std.debug.print("{any}\n", .{squares.items});
}

Salam makes the opposite trade. Collections such as Vector and HashMap use one global allocator, so there is no allocator argument anywhere, but freeing is still explicit, with the same defer idiom:

Salam
func main:
    mut squares := Vector {} as Vector<i64>
    defer squares.free()
    repeat 1 to 5 in i:
        squares.push((i * i) as i64)
    end
    println squares
end
Output[1, 4, 9, 16, 25]

Salam's version is shorter because it decides for you. When you need arenas or fixed-size pools, the mem package has NewArena, ArenaAlloc, ArenaReset and NewPool, but they hand out raw memory rather than plugging into the collections the way a Zig allocator does. If your program must control every allocation, for example in an embedded system or a database with a strict memory budget, Zig's model is more flexible.

For finding mistakes, Zig's DebugAllocator reports leaks when it is deinitialized, and Debug and ReleaseSafe builds check bounds and integer overflow at runtime. Salam checks array and vector indices at runtime in normal builds (not with --release), proves some indices out of range while compiling, reports some use-after-free and double-free patterns before the program runs, and salam memcheck runs a program under Valgrind or AddressSanitizer. Neither language proves memory safety the way Rust's borrow checker does. The article Memory management in Salam covers Salam's side in depth.

comptime vs Salam's compile-time features

comptime is where Zig is in a different league. A Zig function can run at compile time when its arguments are known, types are values that functions can take and return, and that is how Zig does generics: ArrayList(u64) is a function call that returns a type. Platform choices use the same mechanism:

Zig
const builtin = @import("builtin");

const config_dir = if (builtin.os.tag == .windows) "AppData" else ".config";

Salam has a set of narrower tools instead of one general one. A top-level if on a compile-time flag keeps only the branch for the target platform, -DNAME adds your own flags, -dNAME=value adds constants, and generics are specialized per type at compile time:

Salam
if SALAM_OS_WINDOWS:
    const CONFIG_DIR := "AppData"
else:
    const CONFIG_DIR := ".config"
end

func main:
    println "config lives under", CONFIG_DIR
    println "built for", SALAM_OS, "on", SALAM_ARCH
end

The output depends on the machine the program is built for, so it is only checked here; on an x86-64 Linux machine it prints .config and built for linux on x64. Constant expressions fold at compile time, and the compiler does a lot of checking at that stage (exhaustive match, index ranges, unused names, the HTML and CSS of the page language). But there is no general way to run your own functions while compiling, and generic code cannot inspect types the way @typeInfo lets Zig code do. If you want to generate serializers, parsers or lookup tables from types inside the language, Zig can and Salam cannot. Salam's JSON codecs are derived by the compiler itself rather than by library code. The conditional compilation lesson lists Salam's flags.

C interop

Both languages treat C as a first-class neighbor. Zig can import a C header directly and call its functions with no wrapper, and its toolchain doubles as a C and C++ compiler (zig cc):

Zig
const std = @import("std");
const c = @cImport(@cInclude("stdlib.h"));

pub fn main() void {
    std.debug.print("{d} {d}\n", .{ c.abs(-7), c.atoi("42") });
}

Salam does not read headers. You declare the C functions you need in an extern: block, with Salam types that match the C ones, and call them like any other function:

Salam
extern:
    func abs(x: int): int
    func atoi(s: str): int
end

func main:
    println abs(-7), atoi("42")
end
Output7 42

Zig's header import is more convenient for a large C API, because you do not retype the declarations. Salam's explicit declarations are more work but make the boundary visible in one place. In the other direction, Salam's export: block publishes functions under plain C names, and salam build --lib builds a shared or static library together with a generated header. Structs pass by value with the platform's C ABI. The C interop lesson and Calling C from Salam cover the details, including link dynamic for libraries.

Cross-compilation and toolchains

Cross-compilation is one of Zig's best features. zig build and zig cc can target many operating systems and architectures from any host, and Zig ships the libc headers and sources it needs, which is why some projects in other languages use Zig only as their C cross-compiler.

Salam cross-compiles with a --target triple, for example salam build app.salam --target=x86_64-w64-windows-gnu. Release builds of the compiler embed LLVM and its linker, and the native backend writes object files itself for x86-64, AArch64, 32-bit x86 and ARM and RISC-V 64, with a built-in linker for some targets. WebAssembly (wasm32-wasi) and freestanding kernels (*-unknown-none) are targets too. Zig's setup is more mature and covers more targets with less configuration. Cross-compiling Salam shows what works today and what each target needs.

Performance

The project's benchmark compares Salam with C, C++, Rust, Go, Python and PHP, but not with Zig, so there is no measured comparison to report here. What can be said: Zig compiles through LLVM for optimized builds, like Salam's release path, and both sit in the class of compiled languages without a garbage collector. On the 39 same-algorithm programs Salam averaged 10.6 ms against 11.7 ms for C and 11.3 ms for Rust, which the page reads as the same range, with Salam ahead on 6 programs, level on 27 and behind on 6. The programs are short, start-up is part of each time and gaps under about 5% are noise. Expect Zig and Salam to land close to each other on similar code, and measure if it matters.

Ecosystem and stability

Both languages change between releases, and both tell you so. Zig's 0.15 release, for example, reworked the standard library's reader and writer interfaces, and code written for earlier versions had to be updated. Salam removed typed local declarations in 2026 and changes its rules from release to release as well.

The ecosystems differ in kind. Zig has a growing community, a package manager built into its build tool and several large open source projects that prove it in production. Salam's community is much smaller and its third-party package registry is new and nearly empty, but its standard library is far larger at the application level: HTTP servers with routing and middleware, database drivers, JSON, YAML and TOML, TLS written in Salam, image codecs, matrices and neural network layers, and a page language that generates HTML and CSS. Zig gives you sharp building blocks and expects you to assemble them; Salam gives you more finished parts.

When to choose which

Choose Zig when:

  • you need precise control over every allocation, with allocators chosen per call;
  • you want compile-time code generation and type reflection inside the language;
  • you are replacing or extending a C codebase and want to import its headers directly;
  • you need cross-compilation to many targets with minimal setup, or a drop-in C compiler.

Choose Salam when:

  • you are writing services, command-line tools or web backends and want the standard library to do most of the work;
  • you prefer a higher-level, readable syntax with enums that carry data, exhaustive match and interfaces;
  • you want one toolchain with tests, formatting, packages, a language server and a page language;
  • you or your team want to write code in Persian as well as English.

Frequently asked questions

Is Salam like Zig?

They share goals: native code, no garbage collector, explicit errors with try, and defer for cleanup. They differ in philosophy: Zig makes memory and compile-time code explicit and programmable, while Salam hides the allocator and offers a larger standard library and a higher-level syntax.

Does Salam have comptime?

Not in Zig's sense. Salam evaluates constant expressions, keeps or drops code with compile-time flags and specializes generics per type, but it cannot run arbitrary functions while compiling.

Does Salam use allocators like Zig?

No. Collections use a global allocator and are freed with .free(), usually through defer. The mem package offers arenas and pools for raw memory when you need them.

Which is faster, Salam or Zig?

There is no published benchmark that compares the two. Both compile through LLVM for optimized builds and have no garbage collector, so they should land in the same range on similar code.