Zig Blocks and Scopes

A block is a region of code surrounded by curly braces { }. Every block creates its own scope — a boundary that controls where variables live and how long they exist. Zig takes blocks further than most languages: a block can return a value, and you can label blocks to jump out of nested structures precisely.

What a Scope Is

  {                   ← scope begins
      const x = 10;  ← x lives here
      const y = 20;  ← y lives here

      // x and y both visible inside
  }                   ← scope ends: x and y are gone

  // x and y are NOT visible here → compile error if you try

Variables declared inside a block exist only for the lifetime of that block. When execution leaves the block, those variables are destroyed. This is not a weakness — it is a design feature that keeps memory usage predictable and prevents variables from leaking into unrelated parts of the program.

Nested Scopes

const outer = 100;

{
    const inner = 200;
    std.debug.print("outer={d}, inner={d}\n", .{outer, inner}); // both visible
    {
        const deepest = 300;
        // outer, inner, deepest all visible here
        std.debug.print("{d}\n", .{deepest});
    }
    // deepest is gone now
    // outer and inner still visible
}
// inner is gone now
// outer still visible
  Scope diagram:
  ┌──────────────────────────────────────┐
  │ outer = 100                          │ ← outermost scope
  │  ┌────────────────────────────────┐  │
  │  │ inner = 200                    │  │ ← middle scope
  │  │  ┌──────────────────────────┐  │  │
  │  │  │ deepest = 300            │  │  │ ← inner scope
  │  │  └──────────────────────────┘  │  │
  │  └────────────────────────────────┘  │
  └──────────────────────────────────────┘

Blocks as Expressions

A Zig block can produce a value using break with a label. This lets you compute a value using multiple steps and assign the result to a variable — without needing a separate function.

const result = blk: {
    const a = 10;
    const b = 20;
    const c = a * b;
    break :blk c + 5;  // the block's value is 205
};

std.debug.print("Result: {d}\n", .{result});  // 205
  blk:  ← label for this block
  {
      compute a, b, c...
      break :blk c + 5  ← exits block with value 205
  }
  result = 205

The label name (blk) is just a name you choose — any identifier works. The colon after the label is required syntax.

Labeled Blocks in if and switch

const category = blk: {
    const score: u32 = 72;
    if (score >= 90)      break :blk "A";
    if (score >= 75)      break :blk "B";
    if (score >= 60)      break :blk "C";
    break :blk "F";
};

std.debug.print("Grade: {s}\n", .{category}); // Grade: C

Variable Shadowing Inside Blocks

const x = 10;
std.debug.print("Outer x = {d}\n", .{x});  // 10

{
    const x = 99;  // shadows the outer x inside this block
    std.debug.print("Inner x = {d}\n", .{x});  // 99
}

std.debug.print("Outer x = {d}\n", .{x});  // 10 again
  Outer scope: x = 10
  ┌──────────────────┐
  │ Inner scope      │
  │ x = 99 (shadow)  │ ← outer x temporarily hidden
  └──────────────────┘
  Back to outer: x = 10

Shadowing is allowed but use it deliberately. Accidental shadowing — where you mean to use the outer variable but accidentally declare a new inner one — creates subtle bugs that are hard to spot.

Breaking Out of Labeled Loops

Block labels work on loops too. This lets you exit a specific outer loop from inside a nested loop, without flags or extra variables:

var found = false;

outer: for (0..5) |row| {
    for (0..5) |col| {
        if (row == 2 and col == 3) {
            std.debug.print("Found at row={d}, col={d}\n", .{row, col});
            found = true;
            break :outer;  // exits the outer for loop
        }
    }
}

std.debug.print("Search done, found={}\n", .{found});
  Grid search:
  row=0: col 0,1,2,3,4 → not found
  row=1: col 0,1,2,3,4 → not found
  row=2: col 0,1,2 → not found
  row=2: col=3    → FOUND → break :outer → exits both loops

Continuing Outer Loops with Labels

outer: for (0..4) |i| {
    for (0..4) |j| {
        if (j == 2) continue :outer;  // skip to next i
        std.debug.print("({d},{d}) ", .{i, j});
    }
}
std.debug.print("\n", .{});
// (0,0) (0,1) (1,0) (1,1) (2,0) (2,1) (3,0) (3,1)

defer Inside Blocks

A defer inside a block runs when that specific block exits, not when the function exits:

std.debug.print("A\n", .{});

{
    defer std.debug.print("C\n", .{});  // runs when block ends
    std.debug.print("B\n", .{});
}

std.debug.print("D\n", .{});

Output:

A
B
C
D

Block as Initialization for Complex Constants

// Compute a lookup table using a block expression
const FIBONACCI = blk: {
    var table: [10]u64 = undefined;
    table[0] = 0;
    table[1] = 1;
    var i: usize = 2;
    while (i < 10) : (i += 1) {
        table[i] = table[i-1] + table[i-2];
    }
    break :blk table;
};

// FIBONACCI = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
// Computed at compile time if values are comptime-known

Why Zig Blocks Are More Powerful Than Most Languages

  Language       │ Block returns value?  │ Block labels?
  ───────────────┼───────────────────────┼──────────────
  C / C++        │ No                    │ goto only
  Java           │ No                    │ Labeled break/continue
  Python         │ No                    │ No
  Rust           │ Yes (last expression) │ 'label: { ... }
  Zig            │ Yes (break :label)    │ Yes, all blocks

Zig's labeled block expressions replace many situations where other languages need helper functions or temporary mutable variables. The result is code where complex initialization logic lives exactly where the value is used, with no side effects escaping the block.

Leave a Comment

Your email address will not be published. Required fields are marked *