Skip to content

Parsing Numbers and Text

Parsing means turning text into data.

Parsing means turning text into data.

For example, this text:

123

can become the integer value:

123

This text:

3.14

can become a floating point value:

3.14

This text:

true

can become a boolean value:

true

Programs parse text all the time. Command-line arguments are text. Environment variables are text. Configuration files often contain text. Network protocols often begin as text. Logs, CSV files, JSON files, and source code are all text formats.

Zig keeps parsing explicit. Text does not automatically become a number. You ask for a specific type, and parsing can fail.

Parsing Integers

Use std.fmt.parseInt to parse an integer.

const std = @import("std");

pub fn main() !void {
    const text = "123";

    const value = try std.fmt.parseInt(u32, text, 10);

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

Output:

value = 123

This call has three important parts:

std.fmt.parseInt(u32, text, 10)

u32 is the integer type you want.

text is the input byte slice.

10 is the base, also called the radix.

Base 10 means normal decimal numbers.

The Result Type Matters

This parses into u8:

const value = try std.fmt.parseInt(u8, "255", 10);

That works because 255 fits inside u8.

This fails:

const value = try std.fmt.parseInt(u8, "256", 10);

A u8 can store values from 0 to 255. The text "256" is too large.

Parsing checks this. Zig does not silently wrap the number.

Signed and Unsigned Integers

Unsigned integer types cannot store negative values.

const value = try std.fmt.parseInt(u32, "-1", 10);

This fails because u32 cannot represent -1.

Use a signed type when negative values are valid:

const value = try std.fmt.parseInt(i32, "-1", 10);

Now parsing succeeds.

Choose the type based on the meaning of the value.

A count might be usize.

An ID might be u64.

A temperature might be i32.

A small byte value might be u8.

Different Bases

The third argument to parseInt is the base.

Decimal:

const a = try std.fmt.parseInt(u32, "255", 10);

Hexadecimal:

const b = try std.fmt.parseInt(u32, "ff", 16);

Binary:

const c = try std.fmt.parseInt(u32, "11111111", 2);

All three produce the number 255.

This is useful when working with file formats, byte flags, colors, permissions, and machine-level data.

Example:

const std = @import("std");

pub fn main() !void {
    const dec = try std.fmt.parseInt(u32, "255", 10);
    const hex = try std.fmt.parseInt(u32, "ff", 16);
    const bin = try std.fmt.parseInt(u32, "11111111", 2);

    std.debug.print("{} {} {}\n", .{ dec, hex, bin });
}

Output:

255 255 255

Handling Parse Errors

Parsing can fail.

The text might contain invalid characters:

12x3

The number might be too large:

999999999999999999999999999999

The text might be empty:

You can handle errors with catch.

const std = @import("std");

pub fn main() void {
    const text = "12x3";

    const value = std.fmt.parseInt(u32, text, 10) catch |err| {
        std.debug.print("could not parse integer: {}\n", .{err});
        return;
    };

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

Output:

could not parse integer: error.InvalidCharacter

In real tools, you often print a clearer message:

std.debug.print("expected a decimal number, got {s}\n", .{text});

The raw error is useful for debugging. A human message is better for users.

Parsing Floating Point Numbers

Use std.fmt.parseFloat for floating point values.

const std = @import("std");

pub fn main() !void {
    const text = "3.14";

    const value = try std.fmt.parseFloat(f64, text);

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

Output:

value = 3.14

The first argument is the float type:

f32

or:

f64

Use f64 by default unless you have a reason to use f32.

Parsing Text with Spaces

Parsing usually expects the input slice to contain only the number.

This may fail:

const value = try std.fmt.parseInt(u32, " 123 ", 10);

The spaces are part of the input.

Trim the text first:

const std = @import("std");

pub fn main() !void {
    const raw = " 123 \n";

    const text = std.mem.trim(u8, raw, " \n\r\t");

    const value = try std.fmt.parseInt(u32, text, 10);

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

std.mem.trim removes matching bytes from both ends.

This call:

std.mem.trim(u8, raw, " \n\r\t")

removes spaces, newlines, carriage returns, and tabs.

Splitting Text

Many text formats contain separators.

This line:

alice,30

has two fields separated by a comma.

You can split it:

const std = @import("std");

pub fn main() !void {
    const line = "alice,30";

    var it = std.mem.splitScalar(u8, line, ',');

    const name = it.next() orelse return error.InvalidInput;
    const age_text = it.next() orelse return error.InvalidInput;

    const age = try std.fmt.parseInt(u32, age_text, 10);

    std.debug.print("name={s}, age={}\n", .{ name, age });
}

Output:

name=alice, age=30

The iterator gives one part at a time.

First call:

it.next()

returns "alice".

Second call returns "30".

If a part is missing, we return an error.

Checking for Extra Fields

The input:

alice,30,admin

has an extra field.

Sometimes that should be an error.

if (it.next() != null) {
    return error.InvalidInput;
}

Now the parser accepts exactly two fields.

Complete example:

const std = @import("std");

pub fn main() !void {
    const line = "alice,30";

    var it = std.mem.splitScalar(u8, line, ',');

    const name = it.next() orelse return error.InvalidInput;
    const age_text = it.next() orelse return error.InvalidInput;

    if (it.next() != null) {
        return error.InvalidInput;
    }

    const age = try std.fmt.parseInt(u32, age_text, 10);

    std.debug.print("name={s}, age={}\n", .{ name, age });
}

This habit matters. A parser should define what it accepts and what it rejects.

Parsing Lines

A file often contains many lines.

alice,30
bob,25
charlie,40

You can split by newline first, then parse each line.

const std = @import("std");

fn parseLine(line: []const u8) !void {
    var it = std.mem.splitScalar(u8, line, ',');

    const name = it.next() orelse return error.InvalidInput;
    const age_text = it.next() orelse return error.InvalidInput;

    if (it.next() != null) {
        return error.InvalidInput;
    }

    const age = try std.fmt.parseInt(u32, age_text, 10);

    std.debug.print("name={s}, age={}\n", .{ name, age });
}

pub fn main() !void {
    const text =
        \\alice,30
        \\bob,25
        \\charlie,40
    ;

    var lines = std.mem.splitScalar(u8, text, '\n');

    while (lines.next()) |line| {
        if (line.len == 0) continue;
        try parseLine(line);
    }
}

Output:

name=alice, age=30
name=bob, age=25
name=charlie, age=40

This example uses a multiline string:

const text =
    \\alice,30
    \\bob,25
    \\charlie,40
;

Each line begins with \\.

Trimming Each Field

Real input often has spaces:

alice, 30

The age field is:

 30

Trim it before parsing:

const age_clean = std.mem.trim(u8, age_text, " \t\r\n");
const age = try std.fmt.parseInt(u32, age_clean, 10);

You may also trim the name:

const name_clean = std.mem.trim(u8, name, " \t\r\n");

Full parser:

const std = @import("std");

fn parseLine(line: []const u8) !void {
    var it = std.mem.splitScalar(u8, line, ',');

    const raw_name = it.next() orelse return error.InvalidInput;
    const raw_age = it.next() orelse return error.InvalidInput;

    if (it.next() != null) {
        return error.InvalidInput;
    }

    const name = std.mem.trim(u8, raw_name, " \t\r\n");
    const age_text = std.mem.trim(u8, raw_age, " \t\r\n");

    const age = try std.fmt.parseInt(u32, age_text, 10);

    std.debug.print("name={s}, age={}\n", .{ name, age });
}

pub fn main() !void {
    try parseLine("alice, 30");
}

Parsing Booleans

The standard library has helpers for many kinds of parsing, but boolean parsing is simple enough to write directly.

const std = @import("std");

fn parseBool(text: []const u8) !bool {
    if (std.mem.eql(u8, text, "true")) return true;
    if (std.mem.eql(u8, text, "false")) return false;
    return error.InvalidBoolean;
}

pub fn main() !void {
    const value = try parseBool("true");

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

This function accepts exactly:

true

and:

false

It rejects everything else.

That is a good parser design. Be clear about accepted input.

Parsing Key-Value Text

Many simple config formats look like this:

host=localhost
port=8080
debug=true

A line parser can split on =.

const std = @import("std");

fn parseBool(text: []const u8) !bool {
    if (std.mem.eql(u8, text, "true")) return true;
    if (std.mem.eql(u8, text, "false")) return false;
    return error.InvalidBoolean;
}

pub fn main() !void {
    const line = "port=8080";

    var it = std.mem.splitScalar(u8, line, '=');

    const key = it.next() orelse return error.InvalidInput;
    const value_text = it.next() orelse return error.InvalidInput;

    if (it.next() != null) {
        return error.InvalidInput;
    }

    if (std.mem.eql(u8, key, "port")) {
        const port = try std.fmt.parseInt(u16, value_text, 10);
        std.debug.print("port = {}\n", .{port});
    }
}

This is not a full configuration parser. It is the beginning of one.

The core idea is simple:

split text

validate the number of fields

trim fields if needed

compare keys

parse values into specific types

Case Sensitivity

String comparison is usually case-sensitive.

std.mem.eql(u8, "true", "true")

is true.

std.mem.eql(u8, "true", "True")

is false.

That may be exactly what you want. Strict formats are easier to test and document.

If you want to accept multiple spellings, write that policy explicitly:

if (std.mem.eql(u8, text, "true")) return true;
if (std.mem.eql(u8, text, "True")) return true;
if (std.mem.eql(u8, text, "1")) return true;

Do not let accepted input grow accidentally. A parser is part of your program’s interface.

Avoid Silent Defaults

A tempting mistake is to return a default value when parsing fails.

Bad idea:

fn parsePort(text: []const u8) u16 {
    return std.fmt.parseInt(u16, text, 10) catch 8080;
}

This hides bad input.

If the user writes:

port=eighty

the program silently uses 8080.

That can be dangerous.

Prefer returning an error:

fn parsePort(text: []const u8) !u16 {
    return std.fmt.parseInt(u16, text, 10);
}

Then the caller can decide what to do.

Defaults are fine when they are intentional, but do not use defaults to hide parse failures.

A Small Complete Parser

Here is a small parser for this input:

name=alice
age=30
debug=true

It fills this struct:

const Config = struct {
    name: []const u8,
    age: u32,
    debug: bool,
};

Full code:

const std = @import("std");

const Config = struct {
    name: []const u8,
    age: u32,
    debug: bool,
};

fn parseBool(text: []const u8) !bool {
    if (std.mem.eql(u8, text, "true")) return true;
    if (std.mem.eql(u8, text, "false")) return false;
    return error.InvalidBoolean;
}

fn parseConfig(text: []const u8) !Config {
    var config = Config{
        .name = "",
        .age = 0,
        .debug = false,
    };

    var lines = std.mem.splitScalar(u8, text, '\n');

    while (lines.next()) |raw_line| {
        const line = std.mem.trim(u8, raw_line, " \t\r\n");
        if (line.len == 0) continue;

        var parts = std.mem.splitScalar(u8, line, '=');

        const raw_key = parts.next() orelse return error.InvalidInput;
        const raw_value = parts.next() orelse return error.InvalidInput;

        if (parts.next() != null) {
            return error.InvalidInput;
        }

        const key = std.mem.trim(u8, raw_key, " \t\r\n");
        const value = std.mem.trim(u8, raw_value, " \t\r\n");

        if (std.mem.eql(u8, key, "name")) {
            config.name = value;
        } else if (std.mem.eql(u8, key, "age")) {
            config.age = try std.fmt.parseInt(u32, value, 10);
        } else if (std.mem.eql(u8, key, "debug")) {
            config.debug = try parseBool(value);
        } else {
            return error.UnknownKey;
        }
    }

    return config;
}

pub fn main() !void {
    const text =
        \\name=alice
        \\age=30
        \\debug=true
    ;

    const config = try parseConfig(text);

    std.debug.print("name={s}\n", .{config.name});
    std.debug.print("age={}\n", .{config.age});
    std.debug.print("debug={}\n", .{config.debug});
}

Output:

name=alice
age=30
debug=true

This parser is small, but it shows the right habits.

It trims whitespace.

It rejects malformed lines.

It rejects unknown keys.

It parses values into specific types.

It returns errors instead of guessing.

What You Should Remember

Parsing turns text into typed data.

std.fmt.parseInt parses integers.

std.fmt.parseFloat parses floating point numbers.

Parsing can fail, so use try or catch.

Choose the output type deliberately.

Use std.mem.trim to remove whitespace.

Use std.mem.splitScalar to split simple text formats.

Validate the number of fields.

Reject invalid input clearly.

Do not silently replace bad input with defaults.

Good parsing code is strict, explicit, and easy to test.