buenomini

The Zig hashmap gotcha: it doesn't copy your data

Zig's HashMap keeps pointers to your keys and values instead of copying them, which is a use-after-free waiting to happen.

2 min readzig, systems-programmingrkv

Note: This is based on Zig 0.15.2. Zig is pre-1.0 and constantly changing.

This one bit me hard when I built a key-value store in Zig knowing little about manual memory management: HashMap doesn't copy your data. It stores pointers to wherever you allocated it.

When you create a HashMap in Zig, it allocates its internal structures:

var map = std.StringHashMap([]const u8).init(allocator);
defer map.deinit();

The allocator is used for buckets, metadata and hash table arrays, and map.deinit() frees only this internal structure. That was a good gotcha. Because it takes an allocator I thought it would also allocate the values and free them when the map was gone, but it doesn't allocate the key/value data you pass to put() at all:

const key = "mykey";
const value = "myvalue";
try map.put(key, value);  // HashMap stores slices (pointer + length)

No copy of the string data. It stores slices pointing to YOUR memory, wherever you allocated those strings.

# The memory ownership problem

Which gives you a subtle bug like this:

// Request-scoped arena allocator
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();  // Frees ALL arena memory at once

// Parse request, allocate keys/values from arena
const key = try arena.allocator().dupe(u8, "mykey");
const value = try arena.allocator().dupe(u8, "myvalue");

// Store in HashMap
try map.put(key, value);

// Request ends, arena is freed
// HashMap now has dangling pointers!

When the arena is freed, the memory key and value pointed to is gone, but the HashMap still points at it. Use-after-free. And it doesn't necessarily crash, it might work 99% of the time and then randomly fail. Or worse, it's a big ass security bug.

# Separate allocators for separate lifetimes

In hindsight it's obvious. Why would a hashmap that needs to persist between TCP requests use an allocator that gets deinitialized when the connection ends... Request-scoped memory (the arena for parsing) and storage-scoped memory (keys and values in the HashMap) have different lifetimes, so you handle them separately, and that means copying:

// 1. Parse request into arena-allocated buffer (temporary)
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();

const parsed_key = try parseKey(arena.allocator(), input);

// 2. Allocate permanent storage from GPA
const stored_key = try gpa.dupe(u8, parsed_key);

// 3. Store GPA-allocated data in HashMap
try map.put(stored_key, stored_value);

// 4. Arena deinit frees parse buffer (safe - data is copied)
// 5. GPA-allocated key/value stays alive in HashMap

One extra allocation + memcpy per PUT. It's necessary because request buffers and storage have different lifetimes. Redis does this too (blessed pattern then :P), it copies data from read buffers into storage allocations.

You could also skip the arena and allocate everything from the GPA:

// Parse and store both use GPA
const key = try gpa.dupe(u8, "mykey");
try map.put(key, value);

// Manually free temporary parse buffers
defer gpa.free(temp_buffer);

That avoids the copy, but then you have to track by hand what's temporary and what's permanent. Which I don't want to do. What is this, C?

# Why Rust doesn't have this problem

In Rust the borrow checker prevents this at compile time:

fn bad() {
    let mut map = HashMap::new();
    {
        let key = String::from("mykey");
        map.insert(key, value);  // key is moved into HashMap
    }  // key can't be dropped - HashMap owns it now
}

Rust's ownership rules make whoever owns the data responsible for freeing it, so you can't accidentally create dangling pointers. At the same time, you mostly don't allocate by hand in Rust as far as I know, the language abstracts it.