Computers can only understand hardware-dependent, instruction-based, low-level languages known as machine languages that consist of binary numbers (0
and 1
, a.k.a., low- and high-voltage levels). Assembly language abstractions help developers reduce the extreme complexity of the machine code, but are also very low-level, platform-dependent languages that map 0
and 1
patterns to readable characters, and it aren’t necessarily human-friendly.
This is why we typically use general-purpose, human-friendly languages like C, C++, Python, Rust, etc., for low-level programming. Some programming languages come as alternatives to existing matured ones. For example, Rust is an alternative to C++, and Kotlin to Java.
This is where Zig comes in for C. In this article, I will help you get started with Zig programming by explaining the theoretical aspects and providing practical examples.
Jump ahead:
Zig is a minimal open-source, fully-featured systems programming language presented as a friendlier alternative to C. It has a minimal Rust-like syntax but maintains C’s simplicity.
The goal of Zig is to solve issues that C developers often face using a futuristic approach with a new C-like, Rust-syntax-influenced grammar. It offers a productive C-interop solution to let C developers migrate their C codebases to Zig incrementally.
Zig is not just a language — it’s a complete, fully-featured toolchain, meaning you can use Zig to create, develop, test, and build programs/libraries without third-party build automation tools. Zig toolchain can also cross-compile C/C++ projects, so you can productively use Zig toolchain to build your existing C/C++ projects. Zig is designed as a low-level, hardware-friendly language for system programming, but its productive, developer-friendly syntax and features make it even more suitable for building any modern software system.
The Zig project was initially started by Andrew Kelley and is now being maintained by the Zig Software Foundation (ZSF). Zig competes with C by offering a complete system programming solution with a productive C-interop.
Zig strives to become a better-C language not only for low-level system programming but for developing general-purpose software systems with the following highlighted features:
A human-friendly, modern language’s goal is to offer a well-designed language syntax that is not atomic as Assembly. If the language’s abstraction is too close to Assembly, the developer may have to write lengthy code. On the other hand, when a language is abstracted to become close to human-readable, it may be too far away from the hardware and may not be suitable for system programming requirements.
Zig offers a lightweight, Rust-like syntax with most features that C offers. It doesn’t offer the complex feature set and syntax that Rust and C++ have, but offers a simplicity-first developer environment like Go.
Performance and security are crucial factors to check out before selecting a specific programming language. The performance of a particular language typically depends on the performance of its standard library, core runtime features, and the quality of the binary generated by the compiler. Meanwhile, a secure design implements boundary checks, overflow handling, and memory scopes, and helps developers reduce critical security vulnerabilities.
The Zig build system offers four build modes that developers can use based on their performance and security requirements. Zig can also understand variable overflows at compile time.
Moreover, it can generate optimized binaries with runtime security checks, as can Rust, and super-lightweight binaries without runtime security checks, as can C. The Zig official documentation claims that Zig is theoretically faster than C due to its LLVM-based optimizations and improved undefined behavior!
Most programming languages have one or more standard compilers and standard library implementations. For example, you can compile C with:
But these two components are not enough for modern system programming requirements. Programmers often need the guardrails that build tools, package managers, and cross-compiling tools provide.
As a result, build tools like CMake, Ninja, Meson, and package managers like Conan became popular in the C ecosystem. Modern languages like Go and Rust offer official inbuilt package managers, build tools/APIs, cross-compilation support, and test runners.
Similarly, Zig has an inbuilt package manager, a build system API, cross-compilation support, and a test runner. This improves Zig’s chances of becoming a better C because it solves critical system programming issues that C (and C++) developers face. From a language design perspective, Zig offers all the features that low-level C developers can expect from a productive C-interop, so C programmers can incrementally migrate their systems to modern Zig without re-writing their entire legacy codebase.
There are thousands of programming languages, but only a fraction of them could become popular among the developer community. More programming languages will still come in the future, with various features aimed at replacing existing languages.
We don’t need to learn all these languages. But, if a new language comes with a promising future and provides strong, valid, and proven arguments for why it should be used as an alternative to an existing language, it’s undoubtedly better to learn its goals and internal design concepts than ignore it entirely. Go 1.0 was released in 2012 as a new minimal language, and it is now a dependency in major tech companies. In the 1990s, Python was a new, experimental scripting language, but now the digital world depends on it.
Similarly, Zig initially appeared in 2016, published its first pre-release in 2017, and has demonstrated its ability as a modern replacement for C. Zig even offered a complete system programming toolchain with a few years of active development and established a promising future. Given its similarity to and interoperability with C, it can also open you up to a wider array of development opportunities in AI development, game development, and more.
Learning Zig not only adds a promising C-adjacent language to your skillset, but it also improves your knowledge about system programming thanks to its clever, performance-security-balanced design.
At the time of publication, the following popular open-source projects and tech companies use the Zig language and its toolchain:
Projects
Project name | Description | Source |
---|---|---|
Bun | Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one | GitHub |
Mach | Mach is a game engine & graphics toolkit for the future. | GitHub |
Companies
Company name | Usecase | Reference |
---|---|---|
Uber | Uber used the Zig C++ compiler to run Uber services on arm64 hardware via the Hermetic CC toolchain (contains Zig codes). | Uber blog |
TigerBeetle | TigerBeetle offers a financial accounting database written in Zig | GitHub |
Now that you know about Zig and why it was introduced, let’s get started developing with Zig practically by learning the language syntax, concepts, and features.
As with any other open-source project, it’s possible to download Zig from the official website or build from the source, but the easiest and most modern way is to install it from your system package manager. I’ve installed the Zig developer toolchain with the following Snap command (run with sudo
) on Ubuntu:
snap install zig --beta --classic
See the official installation guide for more information about Zig installation methods.
After installing Zig, verify your installation status by entering zig
on your terminal:
We have a working version of the Zig toolchain, so let’s start writing programs in Zig. We’ll write a small Hello, World!-type program to understand the basic source code structure with toolchain basics.
Create a new file named hello.zig
and add the following source code:
const std = @import("std"); pub fn main() void { std.debug.print("Hello Zig!\n", .{}); }
Here, we import the standard library and load its reference to the std
constant in the first line. Then, the main
public function, which returns nothing (void
), uses the print
function to print a debug message on the terminal.
Run the above code with the following command:
zig run hello.zig
You will see Hello Zig!
in your terminal. The print
function offers a similar API to C’s printf
function and lets us use formatted strings via the second parameter:
const std = @import("std"); pub fn main() void { std.debug.print("Hello {s}! {d}\n", .{"Zig", 2023}); // Hello Zig! 2023 }
You can also omit the format types for atomics as follows:
std.debug.print("Hello {}\n", .{2023}); // Hello 2023
Creating a binary is a mandatory requirement for any software release. Zig lets you cross-compile binaries with four build configurations based on performance and security requirements.
Create a binary for your Hello, World! program:
zig build-exe --name hello-bin hello.zig
The above command generates a debug binary named hello-bin
with the default Debug
build configuration, so we can execute it via ./hello-bin
as follows:
You can use the other three build configurations with the -O {mode}
flag:
Compiler flag | Runtime safety checks | Optimizations |
---|---|---|
-O Debug |
Yes | No |
-O ReleaseSafe |
Yes | Yes (speed) |
-O ReleaseSmall |
No | Yes (size) |
Like GNU C, Zig generates a binary for the current target (CPU and OS) by default, so the above commands generated x86 Linux binaries for me on my computer. Also, it’s possible to cross-compile binaries with the -target
flag.
For example, the following command cross-compile a .exe
file for x64 Windows systems:
zig build-exe -target x86_64-windows --name hello-bin.exe hello.zig
Zig’s compilation command-line interface works the same as GNU C with command-line flags, so we may often need to use CLI flags repetitively for each compilation attempt. In C, we typically solve this problem by writing a shell script for the build process or using a fully-featured build system like CMake.
Build systems let you store compiler flags in a configuration file and even add custom build steps. Zig exposes an inbuilt builder API via the std
package as a replacement for separate, third-party build systems. It also offers the zig build
command to execute build steps you store in the build.zig
file.
You can scaffold a Zig build project with the following commands according to your preference:
zig init-exe
: Initializes a Zig builder-based executable applicationzig init-lib
: Initializes a Zig builder-based libraryThe previous demo app was an executable-type app, so we can use the first command to learn about the Zig build system:
mkdir zig-exe-demo cd zig-exe-demo zig init-exe
The above command creates a new executable program with a demo source in src/main.zig
. It adds several build steps to the build.zig
file and we can execute them using the zig build
command instead of zig run
or zig build-exe
.
For example, you can run your program by executing the run
step as follows:
zig build run
You can create binaries using the install
step:
zig build install ./zig-out/bin/zig-exe # Runs the demo program binary
You can indeed add more build steps or intermediate processing by modifying the build.zig
file, as you’d typically do in other build systems like CMake. This undoubtedly simplifies the Zig development process since you can run all your build automation steps via a single command, zig build
.
Now you know how to write a basic Zig program, compile it using the Zig compiler, and use Zig’s inbuilt build system to simplify the development process. Let’s start learning the language syntax and features!
Like C, Zig supports various forms of integers, floating point numbers, and pointers. Let’s learn some primitive types that you should know.
The following code snippet defines some integers and floating-point numbers:
const std = @import("std"); pub fn main() void { var x: i8 = -100; // Signed 8-bit integer var y: u8 = 120; // Unsigned 8-bit integer var z: f32 = 100.324; // 32-bit floating point std.debug.print("x={}\n", .{x}); // x=-100 std.debug.print("y={}\n", .{y}); // y=120 std.debug.print("z={d:3.2}\n", .{z}); // z=100.32 }
Since the above identifier values never get changed, we can use the const
keyword instead of var
:
const x: i8 = -100; // ... // ...
As a modern language, Boolean types are also supported:
var initialized: bool = true;
You can store characters in unsigned bytes (8-bit integers) as follows:
const std = @import("std"); pub fn main() void { const l1: u8 = 'Z'; const l2: u8 = 'i'; const l3: u8 = 'g'; std.debug.print("{c}-{c}-{c}\n", .{l1, l2, l3}); // Z-i-g }
You can also define variables without writing their data types, as follows. Then, Zig will use comptime
types to store them by guaranteeing the compile-time evaluation:
Zig also supports native C types (i.e., c_char
, c_int
, etc.). See all supported types in this table in the official documentation. There is no inbuilt string
type in Zig, so instead, we have to use byte arrays. We’ll discuss arrays in a separate section of this tutorial.
Zig offers a simple syntax to define and access enumerations. Look at the following sample source:
const std = @import("std"); pub fn main() void { const LogType = enum { info, err, warn }; const ltInfo = LogType.info; const ltErr = LogType.err; std.debug.print("{}\n", .{ltInfo}); // main.main.LogType.info std.debug.print("{}\n", .{ltErr}); // main.main.LogType.err }
Zig lets you override the ordinal values of enums as follows:
const LogType = enum(u32) { info = 200, err = 500, warn = 600 };
Zig recommends you use arrays for compile-time-known values and slices for runtime-known values. For example, we can store English vowels in a constant character array as follows:
const std = @import("std"); pub fn main() void { const vowels = [5]u8{'a', 'e', 'i', 'o', 'u'}; std.debug.print("{s}\n", .{vowels}); // aeiou std.debug.print("{d}\n", .{vowels.len}); // 5 }
Here, we can omit the size since it’s known at compile time:
const vowels = [_]u8{'a', 'e', 'i', 'o', 'u'}; // notice the "_"
You don’t need to use this approach to define strings because Zig lets you define strings in C-style, as follows:
const std = @import("std"); pub fn main() void { const msg = "Ziglang"; std.debug.print("{s}\n", .{msg}); // Zig std.debug.print("{}\n", .{@TypeOf(msg)}); // *const [7:0]u8 }
Once you store a hardcoded string in an identifier, Zig automatically uses a null-terminated array reference (pointer to an array) *const [7:0]u8
to store elements. Here, we used the @TypeOf()
built-in function to get the type of the variable. You can browse all supported built-in functions in the official documentation.
Arrays can be repeated or concatenated with the **
and ++
operators, respectively:
const std = @import("std"); pub fn main() void { const msg1 = "Zig"; const msg2 = "lang"; std.debug.print("{s}\n", .{msg1 ** 2}); // ZigZig std.debug.print("{s}\n", .{msg1 ++ msg2}); // Ziglang }
Zig slices are almost like arrays, but they are used to store values that are not known at compile-time (but are known at runtime). Look at the following example that makes a slice from an array:
const std = @import("std"); pub fn main() void { const nums = [_]u8{2, 5, 6, 4}; var x: usize = 3; const slice = nums[1..x]; std.debug.print("{any}\n", .{slice}); // { 5, 6 } std.debug.print("{}\n", .{@TypeOf(slice)}); // []const u8 }
Here, the slice
identifier became a slice because x
was a runtime-known variable. If you use const
for x
, slice
will become a pointer to an array (*const [2]u8
) because x
is known at compile-time. We’ll discuss pointers in an upcoming section.
Structs are useful data structures for storing multiple values, and can even be used to implement object-oriented programming (OOP) concepts.
You can create structs and access their inner fields using the following syntax:
const std = @import("std"); pub fn main() void { const PrintConfig = struct { id: *const [4:0] u8, width: u8, height: u8, zoom: f32 }; const pc = PrintConfig { .id = "BAX1", .width = 200, .height = 100, .zoom = 0.234 }; std.debug.print("ID: {s}\n", .{pc.id}); // ID: BAX1 std.debug.print("Size: {d}x{d} (zoom: {d:.2})\n", .{pc.width, pc.height, pc.zoom}); // Size: 200x100 (zoom: 0.23) }
Structs can also have methods in Zig, so I will show an example when we discuss functions.
Zig unions are like structs, but they can have only one active field at a time. Look at the following example:
const std = @import("std"); pub fn main() void { const ActionResult = union { code_int: u8, code_float: f32 }; const ar1 = ActionResult { .code_int = 200 }; const ar2 = ActionResult { .code_float = 200.13 }; std.debug.print("code1 = {d}\n", .{ar1.code_int}); // code1 = 200 std.debug.print("code2 = {d:.2}\n", .{ar2.code_float}); // code2 = 200.13 // std.debug.print("code2 = {d:.2}\n", .{ar2.code_int}); // error! }
Every programming language typically offers control structures to handle the logical flow of programs. Zig supports all general control structures, like if
, switch
, for
, etc.
Look at the following example code snippet of an if...else
statement:
const std = @import("std"); pub fn main() void { var score: u8 = 100; if(score >= 90) { std.debug.print("Congrats!\n", .{}); std.debug.print("{s}\n", .{"*" ** 10}); } else if(score >= 50) { std.debug.print("Congrats!\n", .{}); } else { std.debug.print("Try again...\n", .{}); } }
This is an example of a switch
statement:
const std = @import("std"); pub fn main() void { var score: u8 = 88; switch(score) { 90...100 => { std.debug.print("Congrats!\n", .{}); std.debug.print("{s}\n", .{"*" ** 10}); }, 50...89 => { std.debug.print("Congrats!\n", .{}); }, else => { std.debug.print("Try again...\n", .{}); } } }
Here’s the example of a while
statement:
const std = @import("std"); pub fn main() void { var x: u8 = 0; while(x < 11) { std.debug.print("{}\n", .{x}); x += 1; } }
And here’s a for
statement example:
const std = @import("std"); pub fn main() void { const A = [_]u8 {2, 4, 6, 8}; for (A) |n| { std.debug.print("{d}\n", .{n}); } }
Functions help us create reusable code segments by allowing us to name each segment with a callable identifier. We’ve already used a public function, main
, to bootstrap our application earlier. Let’s create more and learn functions further.
Here is a simple function that returns the summation of two integers:
const std = @import("std"); fn add(a: i8, b: i8) i8 { return a + b; } pub fn main() void { const a: i8 = 10; const b: i8 = -2; const c = add(a, b); std.debug.print("{d} + {d} = {d}\n", .{a, b, c}); // 10 + -2 = 8 }
Recursion is also a programming feature that Zig offers, as do many other general-purpose languages:
const std = @import("std"); fn fibonacci(n: u32) u32 { if(n == 0 or n == 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); } pub fn main() void { std.debug.print("{d}\n", .{fibonacci(2)}); // 1 std.debug.print("{d}\n", .{fibonacci(12)}); // 144 }
As Go does, Zig lets you create methods within structs and use them as OOP methods, as shown in the following example:
const std = @import("std"); const Rectangle = struct { width: u32, height: u32, fn calcArea(self: *Rectangle) u32 { return self.width * self.height; } }; pub fn main() void { var rect = Rectangle { .width = 200, .height = 25 }; var area = rect.calcArea(); std.debug.print("{d}\n", .{area}); // 5000 }
Zig supports C-like pointers as a hardware-friendly, somewhat low-level language. Look at the following basic integer pointer:
const std = @import("std"); pub fn main() void { var x: u8 = 10; var ptr_x = &x; ptr_x.* = 12; std.debug.print("{d}\n", .{x}); // 12 std.debug.print("{d}\n", .{ptr_x}); // [email protected]_address std.debug.print("{}\n", .{@TypeOf(ptr_x)}); // *u8 }
Here, C/C++ developers need to notice that we used the ptr.*
syntax to de-reference a pointer, not *ptr
as in C/C++. Pointers to array elements and pointers to the whole array also work as expected, as shown in the following code snippet:
const std = @import("std"); pub fn main() void { var A = [_]u8 {2, 5, 6, 1, 1}; var ptr_x = &A[1]; ptr_x.* = 12; std.debug.print("{d}\n", .{A[1]}); // 12 std.debug.print("{d}\n", .{ptr_x}); // [email protected]_address std.debug.print("{}\n", .{@TypeOf(ptr_x)}); // *u8 var ptr_y = &A; ptr_y[2] = 11; std.debug.print("{any}\n", .{A}); // { 2, 12, 11, 1, 1 } std.debug.print("{}\n", .{@TypeOf(ptr_y)}); // *[5]u8 }
Here is a summary of some of Zig’s advanced language features that you should know:
defer
keywordasync
, suspend
, and resume
) for modern asynchronous programming@as
-lc
flag to link against libc:
const std = @import("std"); const c = @cImport({ @cInclude("stdio.h"); }); pub fn main() void { const char_count = c.printf("Hello %s\n", "C..."); // Hello C... std.debug.print("{}\n", .{@TypeOf(char_count)}); // c_int std.debug.print("{}\n", .{char_count}); // 11 }
Keep an eye on the official Zig news page for the latest feature additions and advanced concept explanations.
We’ve discussed the Zig language syntax and features with the previous examples, but those concepts are not enough to develop general-purpose programs — we often need to use complex data structures, mathematical formulas, and operating-system-level APIs. Zig offers a fully-featured, but minimal standard library via the std
namespace.
We’ll write a simple CLI program to learn several Zig standard library features. Add the following code to a new Zig source file (you can also fork the Gist here) and run it:
This demo CLI supports three integer actions:
0
: Instantiates the program1
: Shows program help2
: Prints the current Node.js version via the Zig child process APILook at the following preview:
Here, we used some error-handling basics and the standard library’s io
namespace and ChildProcess
struct. See all available namespaces and structs from the official standard library reference.
Zig is a new language, so the open-source package availability and the developer resources are still growing. Check the following popular open-source Zig libraries:
zigzap/zap
: A micro-framework for building web backendsJakubSzark/zig-string
: A string library for Zigkooparse/zalgebra
: Linear algebra library for games and real-time graphicszigimg/zigimg
: Zig library for reading and writing different image formatsziglibs/ini
: A simple INI parser for ZigSee more stuff developed with/for Zig from the awesome-zig
repository.
The following table summarizes several important comparison factors for Zig, C, and Rust:
Comparison factor | Zig | C | Rust |
---|---|---|---|
Language influence | Rust, C, and Python | B | Functional languages, C++ |
Initial release | 2017 (0.1.0) | 1972 | 2012 (0.1.0) |
Language syntax complexity | Minimal | Minimal | Complex |
Primary paradigms | Procedural | Procedural | Functional and procedural |
Memory management | Manual (via allocators) | Manual (via malloc , etc) |
Manual (but improved to avoid security issues) |
Performance and binary size | Generates super-lightweight, faster binaries that have no dedicated runtime (can optionally link against libc) | Generates super-lightweight, faster binaries that have a minimal runtime known as C runtime | Generates faster binaries that have no dedicated runtime, and binary-size-related issues can be solved with these tips |
Third-party library ecosystem | Still growing, as a new language | Mature library ecosystem, but has no standardized library registry and integration method (depends on build tools) | Has a mature library ecosystem in the official package registry, called Crates |
Developer resources | Good, but only a few sources are available expect the official documentation | Good | Good |
Standard library features | Good | Has low-level APIs only — need third-party libraries or have to write platform-specific code | Good |
Toolchain | Fully-featured | Compiler, linker, and assembler only — needs separate tools to run tests, advance building, use packages, etc. | Fully-featured |
C-interop | Direct imports without FFI (Foreign Function Interface) | N/A | via Rust FFI |
In this tutorial, we learned the concepts, goals, and design techniques behind the development of the Zig programming language. We learned the Zig language by testing generic, general-purpose programming facts that we can use to build modern computer programs.
Zig is still a new language, and more features are still being regularly implemented and tested by ZSF. Learning Zig is a great decision since it has a promising future as a better C language.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
One Reply to "Getting started with the Zig programming language"
Zig 0.12 introduced requirement where you must declare your var a const if you never modify it.