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:
prefixscopes the contest — you compare paths to the same destination.weightis the first branch.local_prefis the next branch when weight ties.as_pathgives us path length as the following tiebreaker.origin_typeis an ordered comparison after path length.next_hopis 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:
- Weight differs? Higher wins (
>). Done.local_pref,as_path,origin_type,next_hopare never even looked at. - Weight ties,
local_prefdiffers? Higher wins. Done. - Both tie, path lengths differ? Shorter wins (
<) — fewer AS hops is better. Done. - Still tied,
origin_typediffers? Lower wins. Done. - Everything above tied? Fall back to comparing
next_hoplexicographically 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:
weightis 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_hopfallback. - The final
next_hopcomparison 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_typeis 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:
- 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? - Once weight differs between two paths, which comparisons never run at all? List them from the function.
- Why does
bestcoming out ofselect_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")