BGP best path selection is just a chain of if statements

This post is part of Protocol in Code, a free series that reads network protocols as logic — inputs, state, and branches — rather than as configuration you paste into a device. The full source and course material live in the repo: github.com/pathvector-studio/protocol-in-code. If you're earlier in your journey and want to build the muscle memory first, start with the hands-on Protocol Lab series and come back here when you want to read the internals.

Today's module is BGP Session 03: best path selection. Most people describe it with a shrug — "BGP just preferred that route somehow." We're going to replace the shrug with a specific answer.

The core question

When a router holds several routes to the same destination and picks one, why did that path win — and which comparison produced the winner first?

That second half matters more than it looks. Best path selection isn't a scoring function that adds up points. It's an ordered sequence of comparisons where the first difference decides everything, and every comparison after that never runs. If you can name the branch that fired, you understand the decision. If you can't, you're guessing.

Nothing to select until there's a contest

Best path logic only means anything once you have more than one path for the same prefix. That constraint is baked right into the entry point:

def select_best_path(paths: list[PathCandidate]) -> PathCandidate:
    if not paths:
        raise ValueError("at least one path is required")

    best = paths[0]
    for candidate in paths[1:]:
        if candidate.prefix != best.prefix:
            raise ValueError("all compared paths must be for the same prefix")
        if _is_better(candidate, best):
            best = candidate
    return best

Two things are worth pausing on here.

First, the shape of the algorithm: it's a running champion. best starts as the first path, and every other candidate is compared pairwise against the current champion. There's no global sort, no table of scores — just "is this new one better than the one I'm holding?" repeated until the list runs out. A field of N candidates collapses to one winner through N-1 comparisons.

Second, the guard: if candidate.prefix != best.prefix. You can only compare paths that are routes to the same destination. Comparing a path to 10.0.0.0/24 against a path to 192.168.0.0/16 is a category error, and the code refuses to do it. This is why "best path" is always scoped to one prefix — the question is never "what's the best route," it's "what's the best route to here."

The candidate carries only what the comparison needs

Before we read the comparison itself, look at what a path actually is in this model:

@dataclass(frozen=True)
class PathCandidate:
    prefix: str
    next_hop: str
    weight: int = 0
    local_pref: int = 100
    as_path: tuple[int, ...] = ()
    origin_type: int = 0

Every field here exists to answer one question during selection:

  • prefix scopes the contest — you compare paths to the same destination.
  • weight is the first branch.
  • local_pref is the next branch when weight ties.
  • as_path gives us path length as the following tiebreaker.
  • origin_type is an ordered comparison after path length.
  • next_hop is the final deterministic fallback.

The dataclass is frozen=True — a candidate is an immutable snapshot of the attributes that decide its fate. Notice there's no score field, no rank field. Rank is not a property of a path; it's the result of comparing two paths, and it lives entirely in the function below.

Read the comparison as branches, not as a formula

Here is the whole of best path selection — the reading target for this session:

def _is_better(candidate: PathCandidate, current: PathCandidate) -> bool:
    if candidate.weight != current.weight:
        return candidate.weight > current.weight
    if candidate.local_pref != current.local_pref:
        return candidate.local_pref > current.local_pref
    if len(candidate.as_path) != len(current.as_path):
        return len(candidate.as_path) < len(current.as_path)
    if candidate.origin_type != current.origin_type:
        return candidate.origin_type < current.origin_type
    return candidate.next_hop < current.next_hop

Read each if the same way: "if this field differs, the decision is made here — return the answer and stop." The function never falls through to a later comparison unless the current field is tied. That single pattern is the whole idea.

Walk it top to bottom:

  1. Weight differs? Higher wins (>). Done. local_pref, as_path, origin_type, next_hop are never even looked at.
  2. Weight ties, local_pref differs? Higher wins. Done.
  3. Both tie, path lengths differ? Shorter wins (<) — fewer AS hops is better. Done.
  4. Still tied, origin_type differs? Lower wins. Done.
  5. Everything above tied? Fall back to comparing next_hop lexicographically so the result is deterministic.

The direction of each comparison is not decoration. Weight and local_pref are "bigger is better" (>). AS path length is "smaller is better" (<) because you want the shorter path through the internet. Get the operator backwards and you've inverted the protocol.

Once you internalize this, the "Reading Lens" for the whole session falls out of it. Stop asking "which path is better?" and start asking three sharper questions:

  • What was the first field that differed between these two paths?
  • Which branch fired first?
  • Which comparisons got skipped because a winner was already decided?

That last question is the one people miss. If two paths have different weights, then AS path length is completely irrelevant — the code returned before it ever measured len(as_path). This is exactly why a route with a beautifully short AS path can still lose: an earlier branch already handed the win to something else.

Note: This is the same "ordered branches, first difference wins" shape you'll meet again and again in this series. DNS resolver ordering, TLS cipher preference, packet-filter rule evaluation — they all read as a sequence of conditions where the first match short-circuits the rest. Once you can read one as code, the others stop being mysterious.

Run it and watch the branches fire

The walkthrough is runnable. It sets up several competing-path scenarios, prints which path won, and — the useful part — tells you the first reason that decided each one:

PYTHONPATH=src python3 examples/bgp/session_03_walkthrough.py

Run it, then for each scenario cover the output and predict the winner and the deciding branch yourself before you look. When your prediction and the printed reason disagree, you've found the exact gap in your mental model — go back to _is_better() and trace which if returned first.

Toy Model Boundary

This is a teaching model, and it deliberately does not reproduce the full RFC 4271 decision process. Be honest with yourself about the gaps before you carry any of this into production reasoning:

  • weight is not a real BGP path attribute. It's a vendor-local preference (Cisco-style), evaluated before anything the RFC defines. It appears here as the first branch because it makes the "earlier branch short-circuits later ones" lesson vivid — not because it's part of the standardized attribute set.
  • The tie-break chain is truncated. The real process includes steps this model skips entirely: MED comparison, eBGP-over-iBGP preference, IGP metric to the next hop, and older/lower-router-ID tie-breaks. Our chain stops at four comparisons plus a next_hop fallback.
  • The final next_hop comparison is a teaching crutch. Comparing next-hop addresses lexicographically is only here to make the toy model fully deterministic so the walkthrough always prints one winner. Real routers break final ties on things like router ID, not string ordering of the next hop.
  • origin_type is a bare integer with "lower wins." Real origin comparison (IGP < EGP < INCOMPLETE) has semantics behind the ordering; here it's just an ordered int to demonstrate another branch.

Read this module as "ordered branches decide the winner, and order changes the result," not as a literal implementation of the RFC tie-break sequence.

Best is not the same as safe

One more boundary, and it's a conceptual one rather than a simplification. select_best_path() answers exactly one question: given these candidates, which one wins the comparison chain? It says nothing about whether the winning route is authorized, whether it's a route leak, or whether the AS path is legitimate. A hijacked prefix with a short AS path and high local_pref will win best path selection cleanly — the code is doing precisely what it was asked, and the answer is still dangerous. "Best" is a preference decision. Authorization and leak detection (RFC 7908) are entirely separate machinery. Don't let the word "best" smuggle in a guarantee of "safe."

Self-check: can you answer these from the source alone?

Don't take my word for any of it. Open src/protocol_in_code/bgp/best_path.py and see if you can answer these purely by reading the code:

  1. Trace two candidates where the one with the shorter AS path loses. Which earlier branch fired, and why did len(as_path) never get measured?
  2. Once weight differs between two paths, which comparisons never run at all? List them from the function.
  3. Why does best coming out of select_best_path() still tell you nothing about whether the route is authorized or a leak?

If you can walk each of those straight off the source without notes, you've got this module. If any one makes you hesitate, that hesitation is pointing at the exact line to reread.

Further reading

  • RFC 4271, Section 3 — BGP operation overview
  • RFC 4271, Section 5 — path attributes
  • RFC 4271, Section 9 — the UPDATE message and the real decision process
  • RFC 7908 — problem definition for BGP route leaks (why "best" ≠ "authorized")

Read more