Hello World & Running
Hello, World
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
try stdout.writeStreamingAll(io, "Hello, World!\n");
} Zig has no implicit prelude or runtime —
const std = @import("std"); is always the first line, and there is no garbage collector or scheduler started behind the scenes. The main(init: std.process.Init) !void signature receives an I/O context, and the ! makes the return an error union. Where Go's fmt.Println adds a newline, Zig requires the explicit \n.Building & running
// Compile and run in one step:
// go run main.go
// Build a binary:
// go build -o app main.go && ./app
// Cross-compile:
// GOOS=linux GOARCH=arm64 go build // Compile and run in one step:
// zig run main.zig
// Build a binary:
// zig build-exe -O ReleaseFast main.zig && ./main
// Cross-compile (no extra toolchain):
// zig build-exe -target aarch64-linux main.zig Both
zig run and go run compile and execute in one step, and both produce a single static binary with no runtime to ship. Zig's cross-compilation is a standout: -target aarch64-linux needs no separate toolchain because Zig bundles libc for every target — closer to Go's GOOS/GOARCH than to a C cross-compiler.Comments
package main
import "fmt"
func main() {
// Single-line comment
/* Block comment */
count := 0
fmt.Println(count)
} const std = @import("std");
/// Doc comment — attaches to the item below, rendered by the toolchain
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
// Single-line comment (Zig has no /* */ block comments)
try stdout.writeStreamingAll(io, "0\n");
} Zig has only line comments:
// for ordinary comments and /// for documentation that attaches to the following declaration. There is no /* ... */ block-comment form at all — unlike Go, which supports both. To comment out a region you prefix every line, which keeps comments from nesting incorrectly.Imports
package main
import (
"fmt"
"strings"
)
func main() {
fmt.Println(strings.ToUpper("zig"))
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const shouted = std.ascii.upperString(&buffer, "zig");
try stdout.writeStreamingAll(io, shouted);
try stdout.writeStreamingAll(io, "\n");
} @import("std") is a built-in that returns the standard library as an ordinary struct value bound to a const; you then reach into it with std.ascii, std.mem, and so on. There is no separate import statement or package list as in Go — a module is just a value, and you can @import("other.zig") your own files the same way.Variables & Types
var and const
package main
import "fmt"
func main() {
count := 0 // mutable
const limit = 100 // immutable
count = 42
fmt.Println(count, limit)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [32]u8 = undefined;
var count: i32 = 0; // mutable
const limit: i32 = 100; // immutable
count = 42;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d} {d}\n", .{ count, limit }));
} Zig writes the type after the name with a colon, and like Go it rejects unused local variables — but Zig goes further: an unused
var that is never mutated is also an error (use const), and every variable must be initialised (write = undefined to opt into uninitialised memory). Discard a value you must keep but not use with _ = value;.Type inference
package main
import "fmt"
func main() {
number := 42 // inferred int
ratio := 3.14 // inferred float64
fmt.Println(number, ratio)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [32]u8 = undefined;
const number = 42; // comptime_int until used
const ratio = 3.14; // comptime_float until used
var count: i32 = 0; // runtime var needs an explicit type
count += 1;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d} {d} {d}\n", .{ number, ratio, count }));
} Zig infers the type of a
const from its initialiser, but integer and float literals get the special compile-time types comptime_int and comptime_float, which coerce to any concrete numeric type at the point of use. A mutable runtime var usually needs an explicit type so the compiler knows its storage size — unlike Go's :=, which always infers from the right-hand side.Integer types
package main
import "fmt"
func main() {
var small int8 = -128
var big uint64 = 18446744073709551615
fmt.Println(small, big)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [48]u8 = undefined;
const small: i8 = -128;
const big: u64 = 18446744073709551615;
const flags: u3 = 5; // arbitrary bit width
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d} {d} {d}\n", .{ small, big, flags }));
} Zig integer types are
i/u plus the exact bit width, the same scheme as Go's int8/uint64 — but Zig allows any width up to 65535 bits, so u3, i128, and u1024 are all real types. There is no platform-dependent int like Go's; if you want a pointer-sized integer you name it usize or isize explicitly.Numeric conversions
package main
import "fmt"
func main() {
count := 100
ratio := float64(count) * 2.5 // explicit conversion
var wider int64 = int64(count)
fmt.Println(ratio, wider)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [48]u8 = undefined;
const count: i32 = 100;
const wider: i64 = count; // widening is implicit (always fits)
const ratio: f64 = @floatFromInt(count); // int -> float needs a builtin
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d:.1} {d}\n", .{ ratio * 2.5, wider }));
} Both languages forbid silent lossy conversions, but they draw the line differently. Go requires an explicit conversion even for guaranteed-safe widening (
int64(count)). Zig allows implicit widening when the value provably fits (i32 into i64) yet requires a dedicated builtin — @floatFromInt, @intFromFloat, @intCast — for any conversion that could lose information.Strings
Strings are byte slices
package main
import "fmt"
func main() {
greeting := "Hello"
fmt.Println(len(greeting))
fmt.Println(greeting[0])
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [32]u8 = undefined;
const greeting: []const u8 = "Hello";
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d} {d}\n", .{ greeting.len, greeting[0] }));
} Like a Go
string, a Zig string is a slice of bytes ([]const u8) and indexing yields a byte, not a character. The length is a field — greeting.len, not len(greeting). A string literal is actually a pointer to a null-terminated array that coerces to a slice, so the terminator is available for C interop while Zig code works with the length-carrying slice.Formatting without allocation
package main
import "fmt"
func main() {
name := "Zig"
version := 0.16
line := fmt.Sprintf("%s v%.2f", name, version)
fmt.Println(line)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [32]u8 = undefined;
const name = "Zig";
const version: f64 = 0.16;
const line = try std.fmt.bufPrint(&buffer, "{s} v{d:.2}", .{ name, version });
try stdout.writeStreamingAll(io, line);
try stdout.writeStreamingAll(io, "\n");
} Where Go's
fmt.Sprintf allocates a fresh string on the heap, Zig's std.fmt.bufPrint formats into a caller-provided stack buffer and returns a slice of it — no allocation, and it returns an error if the buffer is too small. The format placeholders differ from Go's verbs: {s} for strings, {d} for integers, {d:.2} for fixed-point floats.Comparing strings
package main
import "fmt"
func main() {
first := "hello"
second := "hello"
fmt.Println(first == second) // compares contents
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
const first = "hello";
const second = "hello";
const equal = std.mem.eql(u8, first, second);
try stdout.writeStreamingAll(io, if (equal) "equal\n" else "different\n");
} Go's
== compares string contents. In Zig, == on two slices compares the pointer and length, not the bytes, so comparing string values requires std.mem.eql(u8, first, second). This is a common surprise coming from Go — the language keeps == meaning "the same memory" and makes content comparison an explicit standard-library call.Arrays & Slices
Fixed-size arrays
package main
import "fmt"
func main() {
var numbers [5]int = [5]int{10, 20, 30, 40, 50}
fmt.Println(numbers, len(numbers))
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [48]u8 = undefined;
const numbers = [5]i32{ 10, 20, 30, 40, 50 };
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{any} {d}\n", .{ numbers, numbers.len }));
} Both languages have true fixed-size arrays whose length is part of the type — Go's
[5]int and Zig's [5]i32. In Zig the size must be known at compile time, so out-of-bounds indexing with a constant is a compile error, and [_]i32{ ... } lets the compiler count the elements for you. The {any} placeholder formats a whole array.Slices
package main
import "fmt"
func sum(values []int) int {
total := 0
for _, value := range values {
total += value
}
return total
}
func main() {
numbers := []int{10, 20, 30, 40, 50}
fmt.Println(sum(numbers[1:4]))
} const std = @import("std");
fn sum(values: []const i32) i32 {
var total: i32 = 0;
for (values) |value| {
total += value;
}
return total;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const numbers = [5]i32{ 10, 20, 30, 40, 50 };
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{sum(numbers[1..4])}));
} A Zig slice
[]T is a pointer-plus-length pair, exactly like a Go slice, and array[1..4] takes a half-open sub-slice. The difference is ownership: a Go slice keeps its backing array alive through the garbage collector, while a Zig slice merely borrows memory whose lifetime you manage — so you must ensure the underlying array outlives the slice.Iterating
package main
import "fmt"
func main() {
numbers := []int{10, 20, 30}
total := 0
for _, value := range numbers {
total += value
}
fmt.Println("total:", total)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [24]u8 = undefined;
const numbers = [3]i32{ 10, 20, 30 };
var total: i32 = 0;
for (numbers) |value| {
total += value;
}
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "total: {d}\n", .{total}));
} Zig's
for iterates a slice or array directly, binding each element between |...| — no index arithmetic, much like Go's for _, value := range. The element comes first, with no throwaway index to ignore. For a counting loop with no collection, Zig uses while instead.Iterating with an index
package main
import "fmt"
func main() {
fruits := []string{"apple", "banana"}
for index, fruit := range fruits {
fmt.Printf("%d: %s\n", index, fruit)
}
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [32]u8 = undefined;
const fruits = [2][]const u8{ "apple", "banana" };
for (fruits, 0..) |fruit, index| {
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}: {s}\n", .{ index, fruit }));
}
} To get an index alongside each value, Zig adds a second range to the
for: for (fruits, 0..) |fruit, index| iterates the slice and a counter in lockstep. Note the order is the reverse of Go's range, which yields index, value — in Zig the value comes first and the index is the trailing capture.Control Flow
if as an expression
package main
import "fmt"
func main() {
score := 75
grade := "fail"
if score >= 60 {
grade = "pass"
}
fmt.Println(grade)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
const score = 75;
const grade = if (score >= 60) "pass" else "fail";
try stdout.writeStreamingAll(io, grade);
try stdout.writeStreamingAll(io, "\n");
} In Zig
if is an expression that yields a value, so you bind its result directly and there is no ternary operator — if (score >= 60) "pass" else "fail" replaces both. Go's if is a statement, requiring the declare-then-assign pattern, and Go likewise has no ternary. Zig requires parentheses around the condition; Go forbids them.while loops
package main
import "fmt"
func main() {
count := 0
for count < 3 { // Go's only loop keyword
count++
}
fmt.Println("count:", count)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [24]u8 = undefined;
var count: i32 = 0;
while (count < 3) : (count += 1) {}
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "count: {d}\n", .{count}));
} Go folds every loop into
for; Zig keeps both for (for collections) and while (for conditions). Zig's while has a distinctive continue-expression: while (count < 3) : (count += 1) runs the : (...) part after each iteration, which keeps the increment visible even when the body uses continue.switch is exhaustive
package main
import "fmt"
func main() {
day := 3
name := ""
switch day {
case 1:
name = "Monday"
case 2, 3:
name = "midweek"
default:
name = "other"
}
fmt.Println(name)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
const day = 3;
const name = switch (day) {
1 => "Monday",
2, 3 => "midweek",
else => "other",
};
try stdout.writeStreamingAll(io, name);
try stdout.writeStreamingAll(io, "\n");
} Both stop after the first match with no fall-through, but Zig's
switch is an expression that returns a value and is exhaustive: every possible input must be covered or the code will not compile. Over an integer that means an else arm is required; over an enum, the compiler instead forces you to list every variant — so adding a variant later flags every switch that forgot it.Blocks that produce a value
package main
import "fmt"
func main() {
result := 0
for index := 0; index < 10; index++ {
if index*index > 50 {
result = index
break
}
}
fmt.Println(result)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const result = search: {
var index: i32 = 0;
while (index < 10) : (index += 1) {
if (index * index > 50) break :search index;
}
break :search 0;
};
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{result}));
} A Zig block can be labelled and used as an expression:
break :search value exits the labelled block and makes it evaluate to that value. This removes the Go pattern of declaring a mutable variable before a loop just to capture a result computed inside it — the block itself becomes the value, and every exit path must produce the same type.Functions
Defining functions
package main
import "fmt"
func add(first, second int) int {
return first + second
}
func main() {
fmt.Println(add(3, 4))
} const std = @import("std");
fn add(first: i32, second: i32) i32 {
return first + second;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{add(3, 4)}));
} A Zig
fn declares each parameter as name: Type and the return type before the body — no func keyword. Parameters are immutable values; to let a function modify the caller's data you pass a pointer (*i32), which Go would instead express by passing a pointer or returning the new value. Only pub fn declarations are visible outside the file.Multiple return values
package main
import "fmt"
func minMax(numbers []int) (int, int) {
low, high := numbers[0], numbers[0]
for _, value := range numbers {
if value < low {
low = value
}
if value > high {
high = value
}
}
return low, high
}
func main() {
low, high := minMax([]int{3, 1, 4, 1, 5})
fmt.Println(low, high)
} const std = @import("std");
fn minMax(numbers: []const i32) struct { low: i32, high: i32 } {
var low = numbers[0];
var high = numbers[0];
for (numbers) |value| {
if (value < low) low = value;
if (value > high) high = value;
}
return .{ .low = low, .high = high };
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [24]u8 = undefined;
const result = minMax(&[5]i32{ 3, 1, 4, 1, 5 });
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d} {d}\n", .{ result.low, result.high }));
} Go returns several values as a tuple-like list and unpacks them with multiple assignment. Zig has no multiple return; instead a function returns an anonymous struct (
.{ .low = ..., .high = ... }) whose type can be written inline. The caller reads named fields (result.low) rather than destructuring positionally, which avoids accidentally swapping the two values.Function values
package main
import "fmt"
func apply(value int, operation func(int) int) int {
return operation(value)
}
func main() {
double := func(number int) int { return number * 2 }
fmt.Println(apply(5, double))
} const std = @import("std");
fn apply(value: i32, operation: *const fn (i32) i32) i32 {
return operation(value);
}
fn double(number: i32) i32 {
return number * 2;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{apply(5, &double)}));
} Zig passes functions as pointers typed
*const fn(i32) i32, taking a function's address with &double. Unlike Go's func literals, a Zig function pointer cannot capture surrounding variables — there are no closures. When you need behaviour plus captured state, you define a struct holding the state and give it a method, passing the struct instead.Structs & Methods
Struct definition
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
corner := Point{X: 3, Y: 4}
fmt.Printf("(%d, %d)\n", corner.X, corner.Y)
} const std = @import("std");
const Point = struct {
x: i32,
y: i32,
};
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [24]u8 = undefined;
const corner = Point{ .x = 3, .y = 4 };
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "({d}, {d})\n", .{ corner.x, corner.y }));
} A Zig struct is bound to a
const name (const Point = struct { ... }) — types are values. Struct literals must name every field (.x = 3); positional initialisation is not allowed, which prevents the bug of silently swapping fields. Like Go, there are no zero values you can rely on implicitly — each field gets a value at construction unless the struct declares a default.Methods
package main
import (
"fmt"
"math"
)
type Point struct {
X, Y float64
}
func (point Point) Distance() float64 {
return math.Sqrt(point.X*point.X + point.Y*point.Y)
}
func main() {
fmt.Printf("%.1f\n", Point{X: 3, Y: 4}.Distance())
} const std = @import("std");
const Point = struct {
x: f64,
y: f64,
fn distance(self: Point) f64 {
return std.math.sqrt(self.x * self.x + self.y * self.y);
}
};
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const corner = Point{ .x = 3, .y = 4 };
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d:.1}\n", .{corner.distance()}));
} Zig methods are functions declared inside the
struct body whose first parameter is self, called with dot syntax (corner.distance()). It maps closely to Go's method receiver, but self is an ordinary explicit parameter — you choose self: Point to read, self: *Point to mutate — rather than declaring the receiver in a separate position before the method name.Mutating through a pointer
package main
import "fmt"
type Counter struct {
Count int
}
func (counter *Counter) Increment() {
counter.Count++
}
func main() {
counter := Counter{}
counter.Increment()
counter.Increment()
fmt.Println(counter.Count)
} const std = @import("std");
const Counter = struct {
count: i32 = 0,
fn increment(self: *Counter) void {
self.count += 1;
}
};
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
var counter = Counter{};
counter.increment();
counter.increment();
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{counter.count}));
} A method that mutates takes
self: *Counter, the direct parallel to Go's pointer receiver (counter *Counter). Zig auto-takes the address when you call a *self method on a mutable value, just as Go does. Note the field default count: i32 = 0, which lets Counter{} construct with no fields named — Zig's version of a usable zero value, but opt-in rather than automatic.Enums & Tagged Unions
Enumerations
package main
import "fmt"
type Color int
const (
Red Color = iota
Green
Blue
)
func main() {
favorite := Green
name := ""
switch favorite {
case Red:
name = "red"
case Green:
name = "green"
case Blue:
name = "blue"
}
fmt.Println(name)
} const std = @import("std");
const Color = enum { red, green, blue };
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
const favorite = Color.green;
const name = switch (favorite) {
.red => "red",
.green => "green",
.blue => "blue",
};
try stdout.writeStreamingAll(io, name);
try stdout.writeStreamingAll(io, "\n");
} A Zig
enum is a real distinct type, not an integer — where Go uses iota to make named int constants, Zig values are Color.green (or just .green when the type is inferred). A switch over an enum must cover every variant, so no else is needed here, and the compiler will reject the code if you add a colour and forget a branch.Tagged unions vs interfaces
package main
import "fmt"
// Go models a closed set of shapes with an interface.
type Shape interface{ Area() float64 }
type Circle struct{ Radius float64 }
func (circle Circle) Area() float64 { return 3.14159 * circle.Radius * circle.Radius }
type Square struct{ Side float64 }
func (square Square) Area() float64 { return square.Side * square.Side }
func main() {
var shape Shape = Circle{Radius: 2}
fmt.Printf("%.2f\n", shape.Area())
} const std = @import("std");
const Shape = union(enum) {
circle: f64,
square: f64,
fn area(self: Shape) f64 {
return switch (self) {
.circle => |radius| 3.14159 * radius * radius,
.square => |side| side * side,
};
}
};
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const shape = Shape{ .circle = 2 };
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d:.2}\n", .{shape.area()}));
} Go expresses "one of a fixed set" with an interface and a method per type. Zig uses a
union(enum) — a tagged union whose tag is an enum, so a value is exactly one variant and a switch pulls out the payload with |radius|. It is checked for exhaustiveness like an enum, and reading the wrong variant panics in debug mode instead of returning a nil interface or garbage.Enums are not integers
package main
import "fmt"
type Direction int
const (
North Direction = iota
East
South
West
)
func main() {
heading := West
fmt.Println(int(heading)) // iota values ARE ints: 3
} const std = @import("std");
const Direction = enum { north, east, south, west };
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const heading = Direction.west;
const ordinal = @intFromEnum(heading); // explicit: 3
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{ordinal}));
} Go's
iota enums genuinely are integers, so int(heading) is a plain conversion and arithmetic on them compiles. A Zig enum is a separate type with no implicit integer value; you must ask for the underlying number with @intFromEnum (and convert back with @enumFromInt). This prevents accidentally doing arithmetic on a category that has no meaningful numeric order.Optionals
Optionals (?T) vs nil
package main
import "fmt"
func findIndex(numbers []int, target int) (int, bool) {
for index, value := range numbers {
if value == target {
return index, true
}
}
return 0, false
}
func main() {
if index, found := findIndex([]int{10, 20, 30}, 20); found {
fmt.Println("found at", index)
}
} const std = @import("std");
fn findIndex(numbers: []const i32, target: i32) ?usize {
for (numbers, 0..) |value, index| {
if (value == target) return index;
}
return null;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [24]u8 = undefined;
if (findIndex(&[3]i32{ 10, 20, 30 }, 20)) |index| {
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "found at {d}\n", .{index}));
}
} Zig encodes "maybe absent" in the type itself:
?usize holds a usize or null. Where Go uses a (value, ok) pair or a nil pointer that you might forget to check, Zig's if (optional) |value| unwraps the value only when it is present, and the compiler will not let you use a ?T as a plain T without unwrapping it first.orelse — supply a default
package main
import "fmt"
func main() {
var configured *string // nil pointer = "not set"
port := "8080"
if configured != nil {
port = *configured
}
fmt.Println(port)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
const configured: ?[]const u8 = null; // not set
const port = configured orelse "8080";
try stdout.writeStreamingAll(io, port);
try stdout.writeStreamingAll(io, "\n");
} The
orelse operator returns the optional's value if present, or the right-hand fallback if it is null — configured orelse "8080" in one expression. It is the concise replacement for Go's nil-check-then-assign. orelse can also run a block (orelse return, orelse unreachable), making the absent case explicit at the use site.Unwrapping safely
package main
import "fmt"
func main() {
lookup := map[string]int{"apples": 5}
if count, ok := lookup["apples"]; ok {
fmt.Println("have", count)
} else {
fmt.Println("none")
}
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [24]u8 = undefined;
const count: ?i32 = 5;
if (count) |value| {
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "have {d}\n", .{value}));
} else {
try stdout.writeStreamingAll(io, "none\n");
}
} The
if (optional) |value| { ... } else { ... } form is Zig's equivalent of Go's comma-ok idiom: the |value| capture binds only on the present branch. When you are certain a value is there, optional.? force-unwraps it and panics on null in safe builds — the deliberate, visible counterpart to dereferencing a Go nil pointer, which instead crashes unpredictably.Error Handling
Error sets vs (value, error)
package main
import (
"errors"
"fmt"
)
var ErrPermission = errors.New("permission denied")
func openPort(port int) (int, error) {
if port < 1024 {
return 0, ErrPermission
}
return port, nil
}
func main() {
result, err := openPort(80)
if err != nil {
fmt.Println("error:", err)
} else {
fmt.Println("opened", result)
}
} const std = @import("std");
const PortError = error{ PermissionDenied };
fn openPort(port: u16) PortError!u16 {
if (port < 1024) return PortError.PermissionDenied;
return port;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [48]u8 = undefined;
if (openPort(80)) |port| {
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "opened {d}\n", .{port}));
} else |err| {
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "error: {s}\n", .{@errorName(err)}));
}
} Zig replaces Go's
(value, error) pair with an error union: PortError!u16 is "either an error from PortError or a u16." Errors are values of an error set, not strings or integers, so you cannot compare one to -1 or ignore it silently. The if (expr) |value| else |err| form handles both arms, and @errorName gives the error's name.try — propagate errors
package main
import (
"fmt"
"strconv"
)
func parseAndDouble(text string) (int, error) {
number, err := strconv.Atoi(text)
if err != nil {
return 0, err // the ubiquitous Go pattern
}
return number * 2, nil
}
func main() {
result, _ := parseAndDouble("21")
fmt.Println(result)
} const std = @import("std");
fn parseAndDouble(text: []const u8) !i32 {
const number = try std.fmt.parseInt(i32, text, 10);
return number * 2;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const result = try parseAndDouble("21");
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{result}));
} try expr is exactly Go's if err != nil { return ..., err } compressed into one keyword: it unwraps a success value or returns the error from the enclosing function. The function's return type !i32 infers the error set from everything it can propagate. This keeps the happy path uncluttered while still forcing every error to be handled or passed up.catch — handle inline
package main
import (
"fmt"
"strconv"
)
func main() {
number, err := strconv.Atoi("not a number")
if err != nil {
number = -1 // fall back on error
}
fmt.Println(number)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const number = std.fmt.parseInt(i32, "not a number", 10) catch -1;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{number}));
} Where
try propagates, catch handles an error right where it happens: expr catch -1 supplies a fallback value, and expr catch |err| { ... } runs a block with the captured error. It is the symmetric partner to orelse for optionals, and it replaces Go's inline if err != nil block when you want to recover rather than propagate.errdefer — cleanup only on error
package main
import "fmt"
// Go has defer but no errdefer; you branch manually.
func makeGreeting(name string) (string, error) {
greeting := "Hello, " + name + "!"
if len(greeting) > 256 {
return "", fmt.Errorf("too long")
}
return greeting, nil
}
func main() {
greeting, _ := makeGreeting("World")
fmt.Println(greeting)
} const std = @import("std");
fn makeGreeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
const greeting = try allocator.alloc(u8, name.len + 8); // "Hello, " + name + "!"
errdefer allocator.free(greeting); // runs ONLY if the next line returns an error
_ = std.fmt.bufPrint(greeting, "Hello, {s}!", .{name}) catch |err| return err;
return greeting;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
const greeting = try makeGreeting(allocator, "World");
defer allocator.free(greeting);
try stdout.writeStreamingAll(io, greeting);
try stdout.writeStreamingAll(io, "\n");
} Zig adds
errdefer alongside defer: it runs its cleanup only when the function returns an error, not on a normal return. This solves the "free on every error path but not on success" problem that Go can only express by duplicating the cleanup or restructuring the function — defer always runs, errdefer runs on the error path, and together they replace manual goto-cleanup logic.Memory & Allocators
Explicit allocators (no GC)
package main
import "fmt"
func main() {
// Go allocates on the heap and the GC reclaims it.
numbers := make([]int, 0, 4)
for index := 0; index < 4; index++ {
numbers = append(numbers, index*index)
}
fmt.Println(numbers)
// no free() — the garbage collector handles it
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
const numbers = try allocator.alloc(i32, 4);
defer allocator.free(numbers);
for (numbers, 0..) |*slot, index| slot.* = @intCast(index * index);
var buffer: [64]u8 = undefined;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{any}\n", .{numbers}));
} There is no garbage collector and no global
malloc in Zig. A function that allocates takes an std.mem.Allocator parameter, so the caller chooses the strategy, and every allocation is paired with an explicit free (here guaranteed by defer). The DebugAllocator even reports leaks at deinit. Go's make and the GC hide all of this; Zig makes ownership and lifetime your responsibility.defer
package main
import "fmt"
func main() {
defer fmt.Println("cleanup runs last")
fmt.Println("working")
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
defer stdout.writeStreamingAll(io, "cleanup runs last\n") catch {};
try stdout.writeStreamingAll(io, "working\n");
} Both languages have
defer for cleanup that runs as the scope exits, in last-in-first-out order. The key difference is scope: Go's defer always runs at function return, while Zig's runs at the end of the enclosing block. That makes Zig's defer usable for tightly scoped cleanup inside a loop body without deferring it until the whole function ends.Dynamic arrays
package main
import "fmt"
func main() {
var items []int // grows automatically
for index := 0; index < 5; index++ {
items = append(items, index*index)
}
fmt.Println(items)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
var items: std.ArrayList(i32) = .empty;
defer items.deinit(allocator);
for (0..5) |index| {
try items.append(allocator, @intCast(index * index));
}
for (items.items) |value| {
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{value}));
}
} Go's
append grows a slice transparently, reallocating behind the scenes. Zig's growable array is std.ArrayList(T): in 0.16 it is "unmanaged", so the allocator is passed to each call (append(allocator, value)) rather than stored. Growth can fail with an out-of-memory error — hence the try — and defer items.deinit(allocator) frees the backing storage. The elements live at items.items.Stack allocator (no heap)
package main
import "fmt"
func main() {
// Go has no stack allocator; everything dynamic goes through the heap + GC.
backing := make([]byte, 0, 64)
backing = append(backing, []byte("hello")...)
fmt.Println(string(backing), len(backing))
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var backing: [64]u8 = undefined;
var fixed_buffer = std.heap.FixedBufferAllocator.init(&backing);
const allocator = fixed_buffer.allocator();
const message = try allocator.dupe(u8, "hello");
try stdout.writeStreamingAll(io, message);
try stdout.writeStreamingAll(io, "\n");
// fixed_buffer.reset() frees everything at once — no per-item free
} A
FixedBufferAllocator hands out memory from a stack array with no heap involved — allocation is just a pointer bump, and reset() frees everything at once. Because it satisfies the same Allocator interface, any code written against std.mem.Allocator works with it unchanged. Go gives you no such control: dynamic memory always goes through the heap and the garbage collector.Comptime
Compile-time computation
package main
import "fmt"
// Go constants are limited to simple constant expressions.
const maxItems = 64
const bufferSize = maxItems * 8
func main() {
fmt.Println(maxItems, bufferSize)
} const std = @import("std");
const max_items: usize = 64;
const buffer_size = max_items * @sizeOf(i64); // computed at compile time
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [32]u8 = undefined;
var storage: [max_items]i32 = undefined; // array size from a comptime const
_ = &storage;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d} {d}\n", .{ max_items, buffer_size }));
} Zig has no preprocessor and no separate constant-expression sublanguage: any top-level
const can run arbitrary code at compile time — arithmetic, @sizeOf, calling functions, even building types. A comptime-known value can size an array ([max_items]i32). Go constants are restricted to a small set of constant expressions and cannot, for example, call a function.Generics via comptime
package main
import "fmt"
func Max[T int | float64](first, second T) T {
if first > second {
return first
}
return second
}
func main() {
fmt.Println(Max(3, 7))
fmt.Println(Max(2.5, 1.5))
} const std = @import("std");
fn max(comptime T: type, first: T, second: T) T {
return if (first > second) first else second;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [24]u8 = undefined;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d} {d:.1}\n", .{ max(i32, 3, 7), max(f64, 2.5, 1.5) }));
} Zig has no separate generics syntax — a generic function simply takes a
comptime T: type parameter, because types are ordinary values at compile time. The compiler generates a specialised copy per type, like Go's monomorphised generics, but there is no constraint list: any type that supports the operations used in the body works, and a mismatch is reported at the call site.Types are values
package main
import (
"fmt"
"reflect"
)
func main() {
value := 42
// Go inspects types at RUNTIME via reflection.
fmt.Println(reflect.TypeOf(value))
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
const value: i32 = 42;
const TypeName = @typeName(@TypeOf(value)); // resolved at compile time
try stdout.writeStreamingAll(io, TypeName);
try stdout.writeStreamingAll(io, "\n");
} Go inspects types at runtime through the
reflect package, which carries a cost and defeats some optimisations. Zig does it at compile time: @TypeOf(value) yields the type as a value and @typeName turns it into a string, all resolved before the program runs. Combined with @typeInfo, this is how Zig generates code and validates structure with no runtime reflection at all.Concurrency
Threads vs goroutines
package main
import "fmt"
func main() {
result := make(chan int)
go func() {
result <- 7 * 7
}()
square := <-result
fmt.Println("square:", square)
} const std = @import("std");
fn computeSquare(result: *i32, value: i32) void {
result.* = value * value;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [24]u8 = undefined;
var result: i32 = 0;
const thread = try std.Thread.spawn(.{}, computeSquare, .{ &result, 7 });
thread.join();
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "square: {d}\n", .{result}));
} Go's
go launches a lightweight goroutine multiplexed onto its runtime scheduler. Zig has no runtime and no goroutines: std.Thread.spawn starts a real OS thread and returns a handle you join() to wait on. There is no built-in channel either — threads communicate through shared memory you pass in (here a *i32), which you must synchronise yourself.Shared counters
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter atomic.Int64
var waitGroup sync.WaitGroup
for index := 0; index < 5; index++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
counter.Add(1)
}()
}
waitGroup.Wait()
fmt.Println(counter.Load())
} const std = @import("std");
fn bump(counter: *std.atomic.Value(i32)) void {
_ = counter.fetchAdd(1, .monotonic);
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
var counter = std.atomic.Value(i32).init(0);
var threads: [5]std.Thread = undefined;
for (&threads) |*thread| {
thread.* = try std.Thread.spawn(.{}, bump, .{&counter});
}
for (threads) |thread| thread.join();
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{counter.load(.monotonic)}));
} For a shared counter, Go reaches for
sync/atomic or a sync.Mutex plus a WaitGroup. Zig's std.atomic.Value(i32) is the direct parallel: fetchAdd(1, .monotonic) increments atomically with an explicit memory ordering. Zig makes the ordering visible at every operation rather than defaulting it, reflecting its general preference for spelling out exactly what the hardware will do.Safety & No Hidden Flow
Integer overflow
package main
import "fmt"
func main() {
var value uint8 = 255
value++ // silently wraps to 0
fmt.Println(value)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const value: u8 = 255;
const wrapped = value +% 1; // explicit wrapping add -> 0
// value + 1 would panic in a safe build (integer overflow)
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{wrapped}));
} Go silently wraps integer overflow, so
uint8(255) + 1 quietly becomes 0. Zig treats overflow as a bug: the ordinary + panics in Debug and ReleaseSafe builds, and you must opt into wrapping with the explicit +% operator (or @addWithOverflow to detect it). The intent — wrap, saturate, or trap — is always visible in the code.Unused values are errors
package main
import "fmt"
func main() {
used := 7
// computed := 42 // "declared and not used" — a compile error in Go too
fmt.Println(used)
} const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [16]u8 = undefined;
const computed: i32 = 42;
_ = computed; // must explicitly discard, or it is a compile error
const used: i32 = 7;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{used}));
} Here Go and Zig agree where most languages do not: both reject an unused local variable at compile time. Zig is even stricter — it also rejects an unused function parameter and a
var that is never mutated — and the escape hatch is _ = value; to explicitly discard something you must keep but not use. Go, similarly, rejects unused imports outright.No hidden allocations
Go's
+ on strings allocates a fresh backing array behind the scenes — convenient, but the cost is invisible. Zig has no hidden allocations: joining strings takes an explicit allocator via std.mem.concat, so memory use is always visible at the call site. Zig also has no operator overloading and no exceptions, so + is always a primitive and control flow never jumps to code you did not write.