Read OSS

Inside Gin's Radix Tree: How Route Matching Works with Zero Allocations

Advanced

Prerequisites

  • Article 1: Architecture Overview
  • Basic understanding of trie/radix tree data structures
  • Go slices and pointer semantics

Inside Gin's Radix Tree: How Route Matching Works with Zero Allocations

The radix tree in tree.go is the reason Gin exists. Forked from Julien Schmidt's httprouter—still credited in the file's copyright header—this data structure is what makes Gin one of the fastest Go web frameworks. It compresses common path prefixes into shared edges, matches routes without heap allocations, and reorders children by access frequency. This article dissects the tree node by node, traces how routes are inserted and looked up, and explains the performance tricks that keep allocations at zero on the hot path.

The Radix Tree Data Structure

A radix tree (also called a Patricia trie or compact prefix tree) compresses chains of single-child nodes into single edges. Where a standard trie would have one node per character, a radix tree stores the entire shared prefix as a single edge label. For HTTP routing, this means /api/users and /api/orders share the edge /api/, with children users and orders branching from there.

The node struct at tree.go#L99-L108 is compact:

type node struct {
    path      string
    indices   string
    wildChild bool
    nType     nodeType
    priority  uint32
    children  []*node
    handlers  HandlersChain
    fullPath  string
}

Each field serves a specific purpose:

Field Purpose
path The compressed edge label (e.g., /api/ or users)
indices One character per child—the first byte of each child's path, concatenated into a string for fast lookup
wildChild Whether this node has a :param or *catchAll child
nType One of static, root, param, or catchAll
priority Access frequency counter, used to reorder children
children Child nodes; wildcard children are always last
handlers The HandlersChain registered at this path (nil for intermediate nodes)
fullPath The complete original route path, used for debugging

The four node types defined at tree.go#L90-L97 represent:

  • static: Regular path segments
  • root: The tree root (one per HTTP method)
  • param: A :name wildcard capturing a single path segment
  • catchAll: A *name wildcard capturing everything after it
graph TD
    ROOT["/ (root)<br/>path='/'<br/>indices='au'"]
    API["api/<br/>nType=static"]
    USERS["users<br/>handlers=✓"]
    ORDERS["orders/<br/>indices=':'"]
    PARAM[":id<br/>nType=param<br/>handlers=✓"]
    ABOUT["about<br/>handlers=✓"]

    ROOT --> API
    ROOT --> ABOUT
    API --> USERS
    API --> ORDERS
    ORDERS --> PARAM

    style ROOT fill:#e1f5fe
    style PARAM fill:#fff3e0

The indices string is a clever optimization. Instead of iterating all children to find a match, the router checks the first byte of the remaining path against the indices string. For a node with children users and orders, the indices string would be "uo". This turns child dispatch into a single-character comparison loop.

Route Registration: addRoute() and Edge Splitting

The addRoute() method at tree.go#L135-L249 is where the tree takes shape. It walks the existing tree, character by character, until the new route diverges from existing routes. At that point, it may need to split an existing edge.

Consider inserting /api/users into a tree that already has /api/orders. The tree currently has an edge with path /api/orders. The new path shares the prefix /api/, so addRoute() splits the edge:

flowchart TD
    subgraph "Before: Single route"
        A1["root<br/>path='/api/orders'<br/>handlers=✓"]
    end

    subgraph "After: Edge split"
        B1["root<br/>path='/api/'<br/>indices='ou'"]
        B2["orders<br/>handlers=✓"]
        B3["users<br/>handlers=✓"]
        B1 --> B2
        B1 --> B3
    end

    A1 -.->|"Insert /api/users"| B1

The splitting logic at lines 156–175 creates a new child node with the remainder of the original edge, copies over the original node's properties, and truncates the current node to the common prefix:

// Split edge
if i < len(n.path) {
    child := node{
        path:      n.path[i:],      // remainder: "orders"
        wildChild: n.wildChild,
        indices:   n.indices,
        children:  n.children,
        handlers:  n.handlers,       // move handlers to child
        priority:  n.priority - 1,
        fullPath:  n.fullPath,
    }
    n.children = []*node{&child}
    n.indices = bytesconv.BytesToString([]byte{n.path[i]})
    n.path = path[:i]               // truncate to "/api/"
    n.handlers = nil                 // parent has no handlers
}

Wildcard parameters add complexity. When addRoute() encounters a : or * in the path, it delegates to insertChild() at tree.go#L288-L397, which creates param or catchAll nodes. Wildcard conflicts are detected eagerly—you can't register both /users/:id and /users/:name because they'd be ambiguous.

The Engine wraps this tree-level insertion at gin.go#L364-L386, tracking maxParams and maxSections for pre-allocation:

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
    root := engine.trees.get(method)
    if root == nil {
        root = new(node)
        root.fullPath = "/"
        engine.trees = append(engine.trees, methodTree{method: method, root: root})
    }
    root.addRoute(path, handlers)

    if paramsCount := countParams(path); paramsCount > engine.maxParams {
        engine.maxParams = paramsCount
    }
    if sectionsCount := countSections(path); sectionsCount > engine.maxSections {
        engine.maxSections = sectionsCount
    }
}

Tip: If you ever see a panic like "conflicts with existing wildcard" during startup, it means you've registered routes with conflicting parameter names at the same path position. Gin detects these at registration time, not at request time.

Route Lookup: The Zero-Allocation getValue() Hot Path

The getValue() method at tree.go#L418-L599 is the most performance-critical function in the entire framework. It's called on every HTTP request and must find the matching handler chain without allocating memory on the heap.

The method signature reveals the zero-allocation strategy:

func (n *node) getValue(path string, params *Params, skippedNodes *[]skippedNode, unescape bool) (value nodeValue)

Both params and skippedNodes are pointers to pre-allocated slices. They're created once per Context in allocateContext() and reused across requests. The key insight is that by passing a pointer to a slice (not a slice directly), the function can append to the slice's backing array without the slice header escaping to the heap.

sequenceDiagram
    participant S as ServeHTTP
    participant H as handleHTTPRequest
    participant T as tree.getValue()
    participant N as node walk loop

    S->>H: handleHTTPRequest(c)
    H->>H: Linear scan method trees
    H->>T: root.getValue(path, c.params, c.skippedNodes, unescape)
    loop Walk tree
        N->>N: Compare path prefix with n.path
        N->>N: Check indices for child dispatch
        alt Wildcard child
            N->>N: Save skippedNode for backtracking
        end
        alt Param node
            N->>N: Extract param value, append to *params
            Note over N: No heap allocation—<br/>grows within pre-allocated capacity
        end
    end
    T-->>H: nodeValue{handlers, params, fullPath}
    H->>H: c.handlers = value.handlers
    H->>H: c.Next()

The parameter saving logic at tree.go#L498-L522 carefully avoids allocation:

if params != nil {
    if cap(*params) < int(globalParamsCount) {
        newParams := make(Params, len(*params), globalParamsCount)
        copy(newParams, *params)
        *params = newParams
    }
    if value.params == nil {
        value.params = params
    }
    i := len(*value.params)
    *value.params = (*value.params)[:i+1]  // grow within capacity
    (*value.params)[i] = Param{Key: n.path[1:], Value: val}
}

Because maxParams was tracked during route registration and the Params slice was pre-allocated to that capacity in allocateContext(), the cap(*params) < int(globalParamsCount) check almost never triggers. The slice grows within its existing capacity, which means no heap allocation.

The skippedNodes slice handles a subtle problem: when a static route and a wildcard route could both match, the tree tries the static route first (via indices dispatch) but saves a "bookmark" in case it doesn't lead to a match. If the static path fails, the tree backtracks to the wildcard. This is visible in the backtracking loop at lines 459–473.

Priority Reordering and Performance Tricks

Gin doesn't just store children in insertion order—it reorders them by access frequency. The incrementChildPrio() method at tree.go#L111-L131 increments a child's priority counter and bubbles it toward the front of the children slice:

func (n *node) incrementChildPrio(pos int) int {
    cs := n.children
    cs[pos].priority++
    prio := cs[pos].priority

    newPos := pos
    for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- {
        cs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1]
    }

    if newPos != pos {
        n.indices = n.indices[:newPos] +
            n.indices[pos:pos+1] +
            n.indices[newPos:pos] + n.indices[pos+1:]
    }
    return newPos
}

This is called during route registration, not during request handling. Routes registered first or more frequently accessed routes bubble to the front, where the linear scan in getValue() finds them faster. The indices string is rearranged in sync with the children slice.

flowchart LR
    subgraph "Method Trees (slice, not map)"
        GET["GET → root"]
        POST["POST → root"]
        PUT["PUT → root"]
        DEL["DELETE → root"]
        DOT["... (≤9 total)"]
    end

    subgraph "Per-method tree"
        ROOT2["root<br/>indices='uao'<br/>(ordered by priority)"]
        U["users/ (prio=150)"]
        A["api/ (prio=80)"]
        O["orders/ (prio=30)"]
        ROOT2 --> U
        ROOT2 --> A
        ROOT2 --> O
    end

The per-method trees themselves are stored as a slice of at most 9 entries at tree.go#L45-L59, not a map[string]*node. This is a conscious performance choice: for 9 or fewer entries, a linear scan beats a map hash. The map would need to hash the method string, handle bucket lookup, and deal with potential cache misses. A linear scan over 9 elements fits in a cache line.

Tip: If you're benchmarking routes, remember that the first child in the indices string is checked first. Routes registered earlier or with higher priority (more sub-routes) get matched faster. In practice, this means your most common routes are naturally optimized.

Special Cases: Escaped Colons, Redirects, and 405 Handling

Escaped Colons

Normally, : starts a parameter wildcard. But what if you need a literal colon in a route path? Gin supports \: as an escape sequence. During registration, the escaped colon is preserved in the tree. Then, on the first request, updateRouteTree() at gin.go#L504-L521 recursively walks the tree and replaces \: with ::

func updateRouteTree(n *node) {
    n.path = strings.ReplaceAll(n.path, escapedColon, colon)
    n.fullPath = strings.ReplaceAll(n.fullPath, escapedColon, colon)
    n.indices = strings.ReplaceAll(n.indices, backslash, colon)
    for _, child := range n.children {
        updateRouteTree(child)
    }
}

This replacement is wrapped in a sync.Once in ServeHTTP(), so it happens exactly once and doesn't affect the request hot path.

Trailing Slash Redirects

When Engine.RedirectTrailingSlash is true (the default), a request to /users that doesn't match but where /users/ has a handler will trigger a 301 redirect (for GET) or 307 redirect (for other methods). The tsr (trailing slash redirect) field in nodeValue signals this condition, and handleHTTPRequest() checks it after a failed match.

Method Not Allowed (405)

When HandleMethodNotAllowed is enabled, and a route isn't found for the requested method, Gin scans all other method trees looking for a matching path. If found, it returns 405 with an Allow header listing the valid methods—compliant with RFC 7231 §6.5.5. This scan at gin.go#L738-L756 is intentionally opt-in because it adds overhead to 404 responses.

flowchart TD
    MATCH{Route matched?}
    MATCH -->|Yes| EXEC[Execute handlers]
    MATCH -->|No| TSR{TSR available?}
    TSR -->|Yes| REDIR[301/307 Redirect]
    TSR -->|No| FIXED{RedirectFixedPath?}
    FIXED -->|Yes, match found| REDIR2[301/307 Redirect]
    FIXED -->|No| METHOD{HandleMethodNotAllowed?}
    METHOD -->|Yes| SCAN[Scan all other method trees]
    SCAN -->|Methods found| RESP405["405 + Allow header"]
    SCAN -->|None| RESP404[404 Not Found]
    METHOD -->|No| RESP404

What's Next

We've now seen how routes are stored and looked up. In the next article, we'll explore the object that getValue() returns its results into: the Context struct. At 1,489 lines, it's Gin's Swiss Army knife—handling flow control, response writing, parameter access, metadata storage, error accumulation, and even implementing Go's context.Context interface. We'll trace its lifecycle from pool allocation through middleware execution to recycling.