How to Use GPIO Pins with Zig and ESP-IDF: A Complete Guide to the Type-Safe Wrapper

The zig-esp-idf-sample repository provides a three-layer GPIO wrapper that converts ESP-IDF C calls into compile-time pin enums and Zig error unions, allowing you to control hardware with try idf.gpio.Level.set(pin, 1) instead of raw C interop.

Working with GPIO pins on ESP32 microcontrollers traditionally requires verbose C code and manual error checking. The kassane/zig-esp-idf-sample project solves this by wrapping the ESP-IDF GPIO driver in idiomatic Zig, providing type-safe pin enumeration, automatic error conversion, and high-level helpers for common operations like digital I/O, interrupts, and pull-up configuration.

Understanding the Three-Layer GPIO Architecture

The GPIO wrapper in imports/gpio.zig organizes functionality into distinct layers that bridge Zig's compile-time features with the ESP-IDF C API.

Compile-Time Pin Enumeration

At build time, the wrapper inspects the ESP-IDF system bindings and generates an exhaustive enum containing only the pins available on your selected target. This prevents compile-time errors when referencing non-existent pins.

In imports/gpio.zig, the Num() function returns @Enum(sys.gpio_num_t, .exhaustive, …), exposing a GpioNum type where specific pins are accessed as .@"2" for GPIO2 or .@"13" for GPIO13.

Low-Level C Interop

The numToC(gpio_num: GpioNum) function in imports/gpio.zig (lines 44-47) performs a simple cast from the Zig enum to the raw C gpio_num_t required by ESP-IDF functions. This conversion happens automatically in higher-level APIs, keeping your application code free of manual casting.

High-Level Type-Safe Operations

The wrapper groups related GPIO operations into ergonomic structs that return Zig errors (!void) instead of raw esp_err_t codes. Every call uses try errors.espCheckError(…) to convert C error codes into Zig's error handling model, allowing you to use catch or if (err) |...| for flow control.

Basic GPIO Operations with Zig

Configuring Pin Direction

Use idf.gpio.Direction.set() to configure a pin as input, output, or open-drain. This wraps the ESP-IDF gpio_set_direction() call with automatic error conversion.

const idf = @import("esp_idf");

// Configure GPIO2 as output
try idf.gpio.Direction.set(.@"2", .output);

// Configure GPIO4 as input
try idf.gpio.Direction.set(.@"4", .input);

The implementation in imports/gpio.zig (lines 70-74) handles the conversion from the Zig Mode enum to the C gpio_mode_t constant.

Reading and Writing Digital Levels

The Level struct provides methods to write digital values and read pin states. Writing returns an error union while reading returns a boolean representing the logic level.

// Set GPIO2 high
try idf.gpio.Level.set(.@"2", 1);

// Set GPIO2 low
try idf.gpio.Level.set(.@"2", 0);

// Read GPIO4 state
const is_high = idf.gpio.Level.get(.@"4");

These functions are implemented in imports/gpio.zig (lines 60-68), wrapping gpio_set_level() and gpio_get_level().

Enabling Internal Pull-Up and Pull-Down Resistors

Configure internal resistors using the PULL struct methods. These wrap the ESP-IDF pull-up and pull-down enable/disable functions.

// Enable pull-up on GPIO4
try idf.gpio.PULL.upEn(.@"4");

// Enable pull-down on GPIO5
try idf.gpio.PULL.downEn(.@"5");

// Disable pull-up on GPIO4
try idf.gpio.PULL.upDis(.@"4");

The implementation in imports/gpio.zig (lines 77-102) provides upEn, upDis, downEn, and downDis methods that map to the corresponding ESP-IDF calls.

Advanced GPIO Features

Handling GPIO Interrupts in Zig

The wrapper provides type-safe interrupt handling through the installISRService, isrRegister, and intrEnable functions. These manage the ESP-IDF GPIO ISR service with Zig error handling.

const std = @import("std");
const idf = @import("esp_idf");
const sys = @import("sys");

fn button_isr(arg: ?*anyopaque) callconv(.c) void {
    // ISR context - keep this minimal
    // Use arg to access shared state if needed
    _ = arg;
}

pub fn setupButtonInterrupt(pin: idf.gpio.Num()) !void {
    // Configure pin as input with pull-up
    try idf.gpio.Direction.set(pin, .input);
    try idf.gpio.PULL.upEn(pin);
    
    // Set interrupt type (falling edge)
    try idf.gpio.setIntrType(pin, .negedge);
    
    // Install ISR service (call once per application)
    try idf.gpio.installISRService(0);
    
    // Register the ISR handler
    var handle: sys.gpio_isr_handle_t = undefined;
    try idf.gpio.isrRegister(button_isr, null, 0, &handle);
    
    // Enable interrupt on the pin
    try idf.gpio.intrEnable(pin);
}

These functions are implemented in imports/gpio.zig (lines 31-44), wrapping gpio_install_isr_service, gpio_isr_handler_add, and related ESP-IDF interrupt management calls.

Configuring Drive Strength and Pin Hold

For power-sensitive applications, the wrapper exposes drive strength configuration and pin hold functionality. These features control output current capability and maintain pin states during deep sleep.

pub fn configurePowerPin(pin: idf.gpio.Num()) !void {
    // Set as open-drain output
    try idf.gpio.Direction.set(pin, .output_od);
    
    // Set drive capability (cap_3 = strongest)
    try idf.gpio.setDriveCapability(pin, .cap_3);
    
    // Enable pin hold (maintains state in deep sleep)
    try idf.gpio.holdEn(pin);
}

The setDriveCapability and holdEn functions in imports/gpio.zig wrap the ESP-IDF gpio_set_drive_capability and gpio_hold_en calls, converting error codes to Zig errors.

Complete Working Examples

This example from main/examples/gpio-blink.zig demonstrates the essential GPIO workflow: pin configuration, level toggling, and RTOS integration.

const std = @import("std");
const idf = @import("esp_idf");

const LED_PIN: idf.gpio.Num() = .@"2";

export fn app_main() callconv(.c) void {
    // Configure GPIO2 as output
    idf.gpio.Direction.set(LED_PIN, .output) catch return;

    var on: u1 = 0;
    while (true) {
        on ^= 1;
        // Toggle LED state
        idf.gpio.Level.set(LED_PIN, on) catch {};
        // Delay 1 second
        idf.rtos.Task.delayMs(1000);
    }
}

This example uses idf.gpio.Num() to access the compile-time pin enumeration, configures direction with error handling via catch, and toggles the LED level in an infinite loop.

Input Reading with Pull-Up Configuration

For reading button states or external signals, combine direction configuration with pull-up resistors and level reading.

const idf = @import("esp_idf");

pub fn readButtonState() !bool {
    const BUTTON_PIN = idf.gpio.Num().@"4";
    
    // Configure as input with internal pull-up
    try idf.gpio.Direction.set(BUTTON_PIN, .input);
    try idf.gpio.PULL.upEn(BUTTON_PIN);
    
    // Read current logic level (true = high, false = low)
    return idf.gpio.Level.get(BUTTON_PIN);
}

This pattern ensures the pin is properly biased when the external circuit is open, preventing floating inputs.

Summary

  • Type-safe pin enumeration: The Num() function in imports/gpio.zig generates a compile-time enum of available GPIO pins for your specific ESP32 target, preventing invalid pin references.
  • Automatic error conversion: All GPIO operations return Zig error unions (!void) instead of raw esp_err_t codes, enabling idiomatic try and catch error handling via espCheckError.
  • Layered API design: The wrapper provides low-level conversion (numToC), mid-level configuration (Direction, Level, PULL), and advanced features (interrupts, drive strength, pin hold) in imports/gpio.zig.
  • Complete ESP-IDF coverage: All major GPIO features including digital I/O, pull-up/down resistors, interrupt service routines, drive capability configuration, and deep-sleep hold functionality are exposed through type-safe Zig functions.

Frequently Asked Questions

How do I configure a GPIO pin as output in Zig?

Use idf.gpio.Direction.set() with the target pin and .output mode. This function in imports/gpio.zig wraps the ESP-IDF gpio_set_direction() call and converts any error to a Zig error union. For example: try idf.gpio.Direction.set(.@"2", .output); configures GPIO2 as an output.

What is the difference between idf.gpio.Num() and raw integer pin numbers?

idf.gpio.Num() returns a compile-time generated enum that contains only the valid GPIO pins for your specific ESP32 target, defined in imports/gpio.zig. This prevents you from referencing non-existent pins (like GPIO20 on chips that lack it) at compile time, whereas raw integers would only fail at runtime.

How do I handle GPIO interrupts in Zig without writing C code?

The wrapper provides installISRService(), isrRegister(), and intrEnable() in imports/gpio.zig. You write the ISR handler as a normal Zig function with callconv(.c), register it with isrRegister(), and enable interrupts with intrEnable(). All error handling uses Zig's try keyword, converting ESP-IDF error codes automatically.

Can I configure drive strength and deep-sleep hold using this wrapper?

Yes. The imports/gpio.zig file exposes setDriveCapability() for configuring output current strength (.cap_0 through .cap_3) and holdEn()/holdDis() for maintaining pin states during deep sleep. These wrap the ESP-IDF gpio_set_drive_capability() and gpio_hold_en() functions while returning Zig error unions.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →