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

> Control ESP-IDF GPIO pins with Zig using type-safe wrappers. This guide shows how to use Zig enums and error unions for safer hardware interaction, simplifying your embedded development.

- Repository: [Matheus C. França/zig-esp-idf-sample](https://github.com/kassane/zig-esp-idf-sample)
- Tags: how-to-guide
- Published: 2026-03-05

---

**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.

```zig
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.

```zig
// 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.

```zig
// 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.

```zig
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.

```zig
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

### Minimal LED Blink Example

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

```zig
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.

```zig
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.