nftables and the nf_tables Subsystem

nftables is the modern Linux packet-classification framework that replaces the four legacy tools — iptables, ip6tables, arptables, and ebtables — with one userspace tool (nft) and one in-kernel subsystem (nf_tables). Where iptables hard-codes a fixed set of matches and exactly one target per rule into a flat blob, nf_tables is a small register-based virtual machine in the kernel: a rule is a sequence of expressions that load packet data into registers, compare them, and emit a verdict, all as bytecode the kernel evaluates (Wikipedia, nftables wiki). This VM design is what makes the framework’s headline features possible: named sets and maps that turn what would have been thousands of sequential iptables rules into a single hash- or tree-backed lookup, verdict maps that branch on a packet field in one step, concatenations for tuple matching, and atomic ruleset replacement with no global lock. nftables was merged into the mainline kernel in 3.13 (released 2014-01-19); on current distributions it is also the backend that iptables-nft quietly drives (Red Hat Developer, 2020).

This note is verified against the Linux 6.12 LTS (2024-11-17) and 6.18 LTS (2025-11-30) source trees; the VM evaluation loop nft_do_chain() is structurally identical in both, so claims are pinned to “the 6.x LTS line” unless a version is named. The nft userspace syntax is described against the nft(8) man page.


Mental Model

Think of nf_tables as a bytecode interpreter that runs once per packet per hook. Each chain holds a compiled program; the kernel walks the program’s rules, and each rule is a little straight-line sequence of expressions operating on a small register file. An expression either loads something into a register (a TCP destination port, a source address, a conntrack state), tests a register against a constant or a set, or acts (writes a packet field, sets a verdict). When a rule produces a terminal verdict the walk stops; otherwise it continues to the next rule. The radical simplification versus iptables is that the match types are not baked into the engine — they are just expressions, so a new protocol field can be supported by teaching userspace a new expression without recompiling the kernel.

flowchart TB
  PKT["Packet (struct sk_buff)<br/>arrives at a netfilter hook"] --> CHAIN
  subgraph CHAIN["base chain → nft_do_chain()"]
    direction TB
    subgraph RULE["one rule = sequence of expressions"]
      direction TB
      E1["payload expr<br/>load tcp dport → reg"]
      E2["lookup expr<br/>reg ∈ @set ? (hash/rbtree)"]
      E3["verdict expr<br/>write accept/drop/jump → verdict reg"]
      E1 --> E2 --> E3
    end
    REGS[("register file<br/>nft_regs: 16×u32 data<br/>+ 1 verdict reg")]
    E1 -.->|writes| REGS
    E2 -.->|reads| REGS
    E3 -.->|writes verdict| REGS
  end
  CHAIN -->|"verdict reg = ACCEPT/DROP/..."| OUT["return verdict to netfilter"]

The nf_tables virtual machine. What it shows: a rule is not “matches + one target” but an ordered list of expressions that read from and write to a shared register file (struct nft_regs: 16 four-byte data registers plus a verdict register), with a lookup expression able to test a register against a whole set in one operation. The insight to take: because matching is a load-then-compare over registers rather than a fixed callback, one expression can consult a hash table or red-black tree of millions of entries in ~O(1)/O(log n) — collapsing what iptables would express as a linear chain of millions of rules into a single lookup.


The In-Kernel Virtual Machine: How nft_do_chain Evaluates a Packet

The evaluation core is nft_do_chain() in net/netfilter/nf_tables_core.c, verified identical in structure in v6.12 and v6.18. Its skeleton:

/* simplified from net/netfilter/nf_tables_core.c, v6.12 (identical in v6.18) */
struct nft_regs regs;                 /* the VM's register file */
struct nft_jumpstack jumpstack[NFT_JUMP_STACK_SIZE];
bool genbit = READ_ONCE(net->nft.gencursor);   /* which generation is live */
do_chain:
    blob = genbit ? rcu_dereference(chain->blob_gen_1)
                  : rcu_dereference(chain->blob_gen_0);   /* double-buffered ruleset */
    rule = (struct nft_rule_dp *)blob->data;
next_rule:
    regs.verdict.code = NFT_CONTINUE;
    for (; !rule->is_last; rule = nft_rule_next(rule)) {       /* linear over rules */
        nft_rule_dp_for_each_expr(expr, last, rule) {          /* over a rule's exprs */
            ... expr_call_ops_eval(expr, &regs, pkt);          /* run one expression */
            if (regs.verdict.code != NFT_CONTINUE)
                break;                                         /* expr set a verdict */
        }
        switch (regs.verdict.code) {
        case NFT_BREAK:    regs.verdict.code = NFT_CONTINUE; continue; /* rule failed, next */
        case NFT_CONTINUE: continue;                           /* rule passed, next */
        }
        break;                                                 /* terminal verdict */
    }
    switch (regs.verdict.code & NF_VERDICT_MASK) {
    case NF_ACCEPT: case NF_QUEUE: case NF_STOLEN: return regs.verdict.code;
    case NF_DROP:   return NF_DROP_REASON(...);
    }
    switch (regs.verdict.code) {
    case NFT_JUMP: jumpstack[stackptr++].rule = nft_rule_next(rule); fallthrough;
    case NFT_GOTO: chain = regs.verdict.chain; goto do_chain;   /* descend into chain */
    }
    ... /* pop jumpstack or fall through to base-chain policy */

The mechanics, expression by expression:

  1. A register file, not a fixed match struct. struct nft_regs (in include/net/netfilter/nf_tables.h) is a union of u32 data[NFT_REG32_NUM] and a struct nft_verdict. NFT_REG32_NUM is 20, of which 16 are general data registers and the rest reserved. The UAPI header explains the history: “nf_tables used to have five registers: a verdict register and four data registers of size 16. The data registers have been changed to 16 registers of size 4” — old NFT_REG_1..4 map onto the new NFT_REG32_00..15 for compatibility. Every expression reads its inputs from and writes its outputs to these registers.
  2. Expressions are the bytecode ops. nft_rule_dp_for_each_expr walks the rule’s expressions; expr->ops->eval(expr, &regs, pkt) runs each. A payload expression copies bytes from the packet (tcp dport) into a register; a meta expression loads metadata (iif, mark); a cmp expression compares a register to a constant; a lookup expression tests a register against a set; an immediate expression writes a verdict. Hot expressions are special-cased inline for speed (nft_cmp_fast_eval, nft_bitwise_fast_eval, nft_payload_fast_eval) before the indirect expr_call_ops_eval is used.
  3. Verdicts drive control flow. The verdict register holds a code. NFT_CONTINUE means “rule’s expressions all passed, go to the next rule”; NFT_BREAK means “an expression failed, abandon this rule” (reset to continue and move on); NF_ACCEPT/NF_DROP/NF_QUEUE/NF_STOLEN are terminal netfilter verdicts that return immediately; NFT_JUMP/NFT_GOTO descend into another chain (jump pushes a return address onto jumpstack, goto does not); NFT_RETURN pops back.
  4. The chain policy applies if the walk falls off the end of a base chain — exactly like an iptables built-in chain’s policy.

Crucial honesty about complexity: nft_do_chain’s outer loop is itself a linear for (; !rule->is_last; rule = nft_rule_next(rule)) walk. A chain that is a long flat list of N sequential rules is traversed O(N) per packet, just like iptables. nftables does not magically make rule lists sub-linear. The algorithmic win comes from a different place — see the next section.


Where the O(1) Actually Comes From: Sets, Maps, and Verdict Maps

The single most important performance idea in nftables is that you stop expressing per-value rules and express one rule that consults a data structure. Suppose you want to allow 50 000 source IPs. In iptables that is (close to) 50 000 rules, walked linearly — O(n) per packet. In nftables it is one rule with a lookup expression against a named set of 50 000 elements, and the set is backed by a hash table or red-black tree, so the test is ~O(1) (hash) or O(log n) (tree) (nftables wiki, Sets).

  • Anonymous sets live inline in a rule (tcp dport { 22, 80, 443 }) and die with the rule; the kernel still backs them with a real data structure, not a rule expansion.
  • Named sets are standalone, named, persistent, and updatable at runtime (add element …) without touching the rule that references them via @name. This is what makes nftables sets a live dataplane primitive (e.g. a fail2ban-style dynamic blocklist updated thousands of times a second).
  • Set backends. The kernel chooses an implementation from the element type and flags: a hash for exact-match scalar sets, a red-black tree (nft_set_rbtree) for interval sets (CIDR ranges), and pipapo (nft_set_pipapo.c — “PIle PAcket POlicies”, by Stefano Brivio of Red Hat, 2019-2020) for the hard case of concatenations of ranges. The choice is automatic; you just declare the set.
  • Maps associate a key with a valuetcp dport map { 80 : jump http_chain, 443 : jump https_chain } looks up the port and yields a value in one step.
  • Verdict maps (vmap) are maps whose value is a verdict, so a single statement both selects and acts: meta iifname vmap { "eth0" : jump wan, "eth1" : jump lan } branches on the input interface in one O(1) lookup instead of a chain of if iif == X jump. This is how production nftables firewalls dispatch — one vmap replaces dozens of sequential comparison rules.
  • Concatenations form tuples: ip saddr . tcp dport is a single composite key you can match against a set or map of (address, port) pairs. Concatenations have been supported since kernel 4.1, with “nearly O(1)” lookup (nftables wiki, differences) — and pipapo is the set backend that makes ranged concatenations efficient.

So the correct statement is: nftables is not unconditionally faster than iptables for a flat rule list; it is faster because it gives you the tools to express the same policy as a constant-time lookup instead of a linear scan. A naively translated iptables ruleset (rule-for-rule) gains little; a redesigned one using sets/maps/vmaps gains enormously.


Atomic Ruleset Replacement — and the Generation Cursor

iptables (legacy) updates the kernel by re-uploading the whole table blob and serialising on /run/xtables.lock. nf_tables does transactional, atomic, lock-free-to-readers updates, and the mechanism is visible right in nft_do_chain: each chain carries two rule blobs, chain->blob_gen_0 and chain->blob_gen_1, and a per-net generation cursor net->nft.gencursor selects which is live. A ruleset update builds the new blob in the inactive slot, then atomically flips the cursor; in-flight packets are reading the old generation under rcu_dereference and finish on it, while new packets pick up the new generation. There is no moment where a half-applied ruleset is visible, and no global file lock — updates are per-network-namespace and incremental.

From userspace this is the nft -f file transaction: nft batches all the changes in a file (or stdin) into one netlink transaction that either fully commits or fully rolls back (nft(8)). The benefits the kernel API confers — “atomic rules updates, per-network-namespace locking, no file-based locking, fast incremental updates” — are exactly the ones iptables-nft inherits by speaking this same API (Red Hat Developer).


The nft Syntax: Families, Tables, Chains, Rules

nft unifies the four legacy tools by making the address family an explicit dimension (nft(8)):

  • ip — IPv4. ip6 — IPv6. inetboth at once (a single base chain filters IPv4 and IPv6, eliminating the iptables/ip6tables script duplication). arp — ARP (replaces arptables). bridge — L2 frames on a bridge (replaces ebtables). netdev — ingress/egress on a specific device, the earliest attach point.

Unlike iptables, there are no built-in tables or chains — nothing is registered until you create it, so an empty nftables has zero overhead at the hooks (nftables wiki, differences). You declare a table (a container for chains, sets, and stateful objects, namespaced by family), then chains. A base chain attaches to the stack with a type, hook, priority, and policy; a regular chain has none of these and exists only as a jump target.

  • Chain types: filter (accept/drop, all families/hooks), nat (address translation; only ip/ip6/inet, on the prerouting/input/output/postrouting hooks), and route (re-route locally generated packets if a routing key changed; output only).
  • Standard priorities (named, mapping to the integers iptables tables used implicitly): raw (−300), mangle (−150), dstnat (−100), filter (0), security (50), srcnat (100). Lower runs first.

A rule is “zero or more expressions followed by one or more statements,” and — unlike iptables — a single rule can take multiple actions by chaining statements (nftables wiki, differences). Statements split into terminal (accept, drop, reject, queue, return — end evaluation) and non-terminal (log, counter, limit, meta mark set, dnat, snat — continue). Counters are opt-in: nftables does not maintain per-rule counters unless you add a counter statement, avoiding the always-on cost iptables pays.


Configuration Example with Line-by-Line Commentary

A complete, idiomatic inet firewall in native nft syntax, exercising sets, a verdict map, and a port-forward:

#!/usr/sbin/nft -f
# Atomic load: nft applies this whole file as one transaction.
 
flush ruleset                       # start clean (atomic with the rest of the file)
 
table inet my_fw {                  # one table covers BOTH IPv4 and IPv6 (inet family)
 
    set allowed_tcp {               # a NAMED SET of allowed destination ports...
        type inet_service           # ...whose element type is a TCP/UDP port number
        elements = { 22, 80, 443 }  # backed by a hash table → O(1) membership test
    }
 
    set blocklist {                 # a dynamic blocklist of source addresses
        type ipv4_addr
        flags dynamic, timeout      # elements can be added at runtime and auto-expire
        timeout 1h                  # each element lives 1 hour unless refreshed
    }
 
    chain inbound {
        type filter hook input priority filter; policy drop;  # base chain; default DROP
 
        iif "lo" accept                                # always allow loopback
        ct state established,related accept            # stateful: let replies back in
        ip saddr @blocklist drop                       # one rule consults the whole set
        tcp dport @allowed_tcp ct state new accept     # one rule, one O(1) set lookup
        # vmap: branch on input interface in a single lookup, not a chain of comparisons
        meta iifname vmap { "eth0" : jump wan_in, "eth1" : jump lan_in }
        log prefix "DROP-IN: " limit rate 5/minute     # log (non-terminal) then fall to policy
    }
 
    chain wan_in { }                                   # regular chains: jump targets only
    chain lan_in { }
 
    chain prerouting {
        type nat hook prerouting priority dstnat;      # NAT chain on the DNAT hook
        tcp dport 8080 dnat to 10.0.0.5:80             # port-forward (one statement)
    }
 
    chain postrouting {
        type nat hook postrouting priority srcnat;
        oifname "eth0" masquerade                      # source-NAT outbound on eth0
    }
}

The teaching points: the inet table filters v4 and v6 in one place; @allowed_tcp and @blocklist are set lookups — each is one rule regardless of how many ports/addresses they hold; the vmap dispatches on interface in a single O(1) step where iptables would need a comparison rule per interface; and the entire file loads as one atomic transaction via nft -f. Compare this with the equivalent iptables ruleset, which needs separate iptables/ip6tables invocations, one rule per allowed port, and a fragile sequence of -A commands with no atomicity.


How iptables Maps Onto nf_tables: the Compat Path

On current systems the iptables command is usually iptables-nft, which speaks the nf_tables netlink API rather than the old setsockopt blob path (Red Hat Developer). Its rules are evaluated by the very nft_do_chain VM described above. But it does not rewrite your iptables matches into native nft expressions — instead the kernel module nft_compat (net/netfilter/nft_compat.c, by Pablo Neira Ayuso, 2012-2013) wraps a legacy struct xt_target/struct xt_match as an nf_tables expression. nft_target_eval_xt() and nft_match_eval() invoke the old xtables callbacks from inside the VM. So an iptables-nft rule is “an nf_tables rule whose one expression is an nft_compat wrapper around an xtables match/target” — it runs in the modern engine but reuses the legacy match code. This is distinct from iptables-translate, a userspace text-to-text tool that converts an iptables command line into native nft syntax for a human to review. The clean separation: same syntax, modern backend, legacy match code (iptables-nft) versus syntactic conversion to native nftables (iptables-translate). See iptables and x_tables for the legacy side in full.


Failure Modes and Common Misunderstandings

  • “nftables is always faster than iptables” — false. A rule-for-rule translation of an iptables ruleset is not faster; both walk a linear rule list. The win is real only when you redesign the policy around sets/maps/vmaps. Quote the kernel: nft_do_chain’s rule loop is linear.
  • Mixing legacy and nft. Running iptables-legacy/x_tables rules and nft/nf_tables rules simultaneously means two kernel subsystems filter the same packets at the same hooks; verdict interleaving is order-dependent and surprising. The nftables wiki explicitly warns against it (wiki).
  • Priority collisions across subsystems. When iptables-nft, firewalld, and hand-written nft rules coexist, they register chains at hooks with priorities that determine ordering. Two base chains at the same hook and priority have undefined relative order — a real source of “my rule is bypassed” confusion.
  • Forgetting the chain has no policy unless it’s a base chain. Regular chains never drop on fall-through; only base chains have a policy. A policy drop on a base chain plus an unreachable accept rule = everything dropped.
  • Set type mismatches. Declaring a set type ipv4_addr and trying to add a CIDR range fails unless the set has flags interval; the right backend (rbtree/pipapo) is only selected when you declare the interval flag.
  • ct state ordering. As with iptables, the stateful-accept rule must precede the new-connection rules, or established replies get dropped by a later default-drop.

Alternatives and When to Choose Them

  • vs x_tables: nftables is the strict successor — choose it for all new firewall work. iptables survives for legacy scripts and because iptables-nft lets the old syntax ride the new backend. Red Hat has begun deprecating even iptables-nft in favour of native nftables.
  • vs XDP / eBPF: for line-rate drop/redirect (DDoS scrubbing, software load balancing), XDP runs an eBPF program in the driver before an sk_buff exists — far faster than any netfilter hook, but with a far more restrictive programming model. nftables is the right tier for policy (stateful firewalling, NAT); XDP for volume. nftables’ own flowtable offload bridges the gap for established forwarded flows (it short-circuits the forwarding path after the first packet).
  • vs IPVS / nft kube-proxy mode: for Service load balancing at scale, the verdict-map/set design of nftables (now an official kube-proxy mode) gives O(1) Service lookup where iptables mode was O(n) — the Kubernetes view lives in Kubernetes MOC.

Production Notes

The clearest production validation of the set/map model is kube-proxy’s nftables mode, added precisely because iptables mode’s O(n) rule explosion melted down on large clusters; nftables maps give constant-time Service-to-endpoint lookup. firewalld switched its default backend to nftables years ago, so most desktop and server distros are already using nf_tables under a familiar UI. The dynamic-set primitive underpins runtime blocklisting tools (a set with flags dynamic, timeout updated from a userspace daemon), and flowtable hardware/software offload is used by routers to push established-flow forwarding off the slow path. The kernel keeps both x_tables and nf_tables compiled in for compatibility, but the trajectory across distributions, firewalld, and kube-proxy is unambiguous: nf_tables is the present and future of Linux packet classification, and the legacy tools are a compatibility shim over it.

Uncertain

Verify: which distributions default to native nftables vs iptables-nft vs deprecating iptables-nft at the 2026 timeframe. This is a per-distro, per-release decision that keeps moving (Red Hat has been deprecating iptables-nft). The kernel facts here (VM, sets, atomic replace, merge in 3.13) are verified against source; the distro-default claims rest on the Red Hat blog (2020) and Wikipedia and are not re-checked against 2026 release notes. To resolve: consult the target distro’s current release notes and nft --version / update-alternatives --display iptables on the actual system. uncertain


See Also