Skip to content

Does deleting a key from a BPF hash map zero out the value memory, and can a reused slot still hold the old value bytes?

Short answer: No. bpf_map_delete_elem unlinks the element from the hash buckets and hands its memory back to the per-CPU freelist (preallocated hash) or to the RCU-deferred BPF allocator (a BPF_F_NO_PREALLOC hash); it does not clear the value bytes. You cannot read a deleted value back through the map API — bpf_map_lookup_elem returns NULL and bpf_map_get_next_key skips it — but the physical bytes stay in the reused slot until a later update overwrites them, and a normal full update overwrites the whole value region on every CPU — with one exception: on the BPF_F_CPU create path for per-CPU hash maps, only the named CPU's slot is written and the other CPUs keep the previous occupant's bytes, so a lookup of the new key (with BPF_F_CPU targeting a non-named CPU) can still read a deleted key's per-CPU values there. Until a zero-fill patch posted to the bpf mailing list on 2026-09-23 is merged (not merged as of the 2026-09-24 mainline fetch), that case is the only cross-key value leak reachable through the map API: on ordinary HASH maps a full update rewrites the whole value region on every CPU before the key becomes visible, and BPF-program creation of a per-CPU value goes through pcpu_init_value, which zeros the non-current CPU slots. The rule: never rely on delete (or LRU eviction) to erase value contents; zero the value yourself, and do not assume embedded kernel object fields (timers, workqueues, task work queues) survive, because the kernel cancels those fields when the element is freed.

What delete actually does

htab_map_delete_elem computes the bucket for the key, takes the bucket lock, and — if the element is present — calls hlist_nulls_del_rcu(&l->hash_node) to unlink it, then free_htab_elem. There is no memset or equivalent on the value region anywhere in that path. What happens to the memory next depends on how the map was created:

  • Preallocated hash (the default). At map creation, prealloc_init allocates all max_entries elements in one contiguous htab->elems pool and fills a per-CPU freelist. On delete the element goes back onto that per-CPU freelist (pcpu_freelist_push). It is immediately reusable — the next same-CPU update pops it back (__pcpu_freelist_pop, LIFO) — and the value bytes were never touched at delete time. The pool itself was zeroed once at map creation (__GFP_ZERO in __bpf_map_area_alloc, kernel/bpf/syscall.c); for per-CPU variants the separate per-CPU value area allocated by bpf_map_alloc_percpu inside prealloc_init is zeroed at creation by the kernel's alloc_percpu zero-fill guarantee (the BPF code passes no __GFP_ZERO flag — the zeroing is a property of alloc_percpu itself). Neither area is re-zeroed on delete; on element reuse, the only post-creation zeroing anywhere is pcpu_init_value zeroing the non-current CPU slots when a BPF program creates a per-CPU value.
  • Lazy hash (BPF_F_NO_PREALLOC). Elements are allocated on demand from the per-CPU BPF memory cache, and on delete htab_elem_free returns them to that allocator. The physical free is RCU-deferred (the allocator's free_by_rcu / call_rcu_tasks_trace path), so the page is not recycled until a grace period completes; the value bytes persist even longer.

The element count is decremented synchronously in both paths (bpf_map_dec_elem_count / dec_elem_count), which is why a delete immediately makes room for a new element even though the underlying memory may be recycled a little later.

Why a full update cannot leak one key's value into another

When a new key is inserted into a recycled element, alloc_htab_elem copies the full key_size bytes and then copies the entire value region (copy_map_value, or a rounded-value_size memcpy). A well-formed, fully-written value therefore completely replaces the previous occupant's bytes. There is no path where you bpf_map_lookup_elem key B and read key A's value through the map API — with one documented exception: on the BPF_F_CPU create path for per-CPU maps, pcpu_copy_value writes only the named CPU's slot and returns early, leaving the other CPUs with the recycled element's previous bytes, so a lookup of the new key returns a deleted key's per-CPU values on those CPUs. A zero-fill patch for that arm (Fixes: c6936161fd55) was posted to the bpf mailing list on 2026-09-23 and is not merged as of the 2026-09-24 mainline fetch. The residual from an ordinary full update or a BPF-program write is a physical-memory fact (the old bytes sit in the slot until overwritten or scrubbed), not a logical leak: on ordinary HASH maps the value region is fully rewritten on every CPU before the key is visible, and BPF-program creation of a per-CPU value zeros the non-current CPU slots via pcpu_init_value. The BPF_F_CPU create-path exception above is the one logical, API-visible leak: until the patch lands, bpf_map_lookup_elem_flags with BPF_F_CPU|cpu<<32 targeting a non-named CPU returns the previous occupant's bytes.

The corners that are not safe

  • The value bytes are not erased. Delete contains no scrub step, and the lazy allocator recycles its objects without a scrub a BPF program can rely on. If a value ever held secret data, delete does not wipe it — the bytes linger in the preallocated pool or the allocator's freelist until reuse. For an explicit delete, the safe pattern — and the only reliable erasure — is to zero the value explicitly before releasing the entry; automatic LRU eviction gives no user-space hook before the element leaves, so an evicted value's bytes may survive in the pool until a later allocation overwrites them. (Creation-time zeroing — __GFP_ZERO for the non-per-CPU pool, the alloc_percpu guarantee for per-CPU value areas — is a one-time event; it does not apply on delete or reuse.)
  • Embedded kernel object fields are cancelled, not preserved. On free, check_and_cancel_fields / bpf_obj_cancel_fields cancel the value's BPF object fields (timers, workqueues, task work queues). For per-CPU hash maps these field types are rejected at map creation by map_check_btf(), so the cancellation is a no-op there; for non-per-CPU maps it is a real guarantee. A program that reads the value after the element is freed or recycled cannot assume an embedded object is still valid.
  • LRU maps evict on their own. BPF_MAP_TYPE_LRU_HASH (and LRU_PERCPU_HASH) evict the least-recently-used element when the map is full, through the same unlink-and-free path (htab_lru_map_delete_node: hlist_nulls_del_rcu + element-count decrement + cancel fields, no zeroing). A key you never called delete on can read back NULL after eviction. With BPF_F_NO_COMMON_LRU each CPU keeps its own LRU list (per-CPU LRU + per-CPU map for LRU_PERCPU_HASH; per-CPU LRU + global map for LRU_HASH), so which element evicts is per-CPU rather than global.

How to verify it

  1. Confirm the unlink. bpf_map_lookup_elem(key) returns NULL right after bpf_map_delete_elem(key), and bpf_map_get_next_key / bpf_map_get_next_key_batch never return the deleted key.
  2. Mind the iteration restart. The docs note that if you pass a cur_key that has just been deleted, bpf_map_get_next_key restarts from the first key in the table; use the batched lookup for a stable sweep.
  3. Contrast with arrays. BPF_MAP_TYPE_ARRAY values are zero-initialized at creation time (documented). Non-per-CPU preallocated hash values are likewise zeroed at map creation (__GFP_ZERO in __bpf_map_area_alloc); per-CPU value areas are zeroed at creation by the alloc_percpu guarantee. What differs: arrays are not recycled element-by-element, whereas hash elements are — and no path re-zeros a hash value on delete (the only post-creation zeroing is pcpu_init_value zeroing the non-current CPU slots when a BPF program creates a per-CPU value). That recycling-without-rezeroing is the whole boundary.
  4. Guarantee clean values. For an explicit delete, the only reliable erasure is to write zeros before delete; automatic LRU eviction has no guaranteed user-space point before the element leaves, so scrub-on-eviction cannot be guaranteed; there is no scrub flag on bpf_map_delete_elem.

Where the answer stops

  • This is about value memory, not the map API's contract: a deleted or evicted key still reads as NULL and is not enumerated, so the logical view is clean even when the physical bytes are not — with the one exception above: on the unfixed BPF_F_CPU create path, a lookup of the new key on a non-named CPU is an API-visible cross-key logical leak until the zero-fill patch lands.
  • Whether the slab later scrubs a recycled page is not a BPF-visible guarantee; a program must not depend on it.
  • The per-CPU LRU flag (BPF_F_NO_COMMON_LRU) gives each CPU its own LRU list (per-CPU LRU + per-CPU map for LRU_PERCPU_HASH; per-CPU LRU + global map for LRU_HASH), changing which element evicts per CPU, but not the unlink-and-free-without-zeroing mechanism.

References

Community discussion today

Honest coverage note: the two watchlist-opted Slack archives returned zero messages in the rolling window today, and the allowlisted Discord channels plus the public bpf mailing list and r/eBPF remain visible-browser-only, with no visible-browser session available in this run — so no private community material was available for 2026-09-23. The question above is the fallback selection: a genuine, recurring practitioner boundary of the monitored eBPF development community, grounded entirely in the public primary sources above (kernel BPF docs and upstream source) rather than in any thread.

The recurring practitioner theme in that space — "my hash-map values go stale, a deleted key still seems to show old data" — resolves to exactly this boundary: deletion is an unlink, not a zeroing, and the element's memory is recycled through a per-CPU freelist (prealloc) or an RCU-deferred allocator (lazy). Two public-doc-anchored follow-ups recur here. First, the LRU surprise: a key that was never deleted can read back NULL once the map is full and the least-recently-used element evicts, which people misread as a delete bug when it is actually capacity eviction. Second, the secret-handling implication: because delete does not scrub the value bytes, a map that once held sensitive data still holds them physically until reuse, so the safe pattern is to zero the value (and drop embedded kernel objects explicitly) before releasing the entry. The boundary the docs leave open is that there is no scrub-on-delete API; keeping value memory clean is a program responsibility, not a kernel one. One public primary-source signal from the same day corroborates that this boundary is live in upstream development: the bpf mailing-list zero-fill patch for the BPF_F_CPU per-CPU create path described above (the patch, with the element-reuse selftest at message 18) — a same-day public signal, not a private-community message; the honest coverage note above is unchanged.