Export policy decides what leaves: reading BGP outbound state as code

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 examples. Each module points at one small Python file and asks you to read it the way you'd read any other code: what comes in, what gets mutated, where does it return early. The source lives at github.com/pathvector-studio/protocol-in-code.

Note: If you're earlier in the journey and want to do things with protocols before dissecting them, start with the hands-on Protocol Lab series instead — it builds the muscle memory this series assumes.

The question

Here's the thing that trips people up when they first look at a real BGP table: why is the route we advertise to a peer not always identical to the route we installed locally?

You run show ip bgp on a router, you see a prefix with a next-hop and an AS path. You run the equivalent command on the peer that received it from you, and the fields don't match. The next-hop changed. The AS path grew. Sometimes the prefix isn't there at all — even though it's clearly still installed on your side.

None of this is mysterious once you stop thinking of advertisement as "copying the table over the wire." It isn't a copy. It's a transform, and it lives in its own function with its own inputs. Session 06 already established that Adj-RIB-Out is a separate place where outbound state lives. This session gives that outbound state its own policy step.

The file

Everything in this module happens in src/protocol_in_code/bgp/export_policy.py. It's short enough to hold entirely in your head, which is the point.

Read it in this order: PeerType, then ExportPolicy, then prepare_export(). Inside prepare_export(), notice four things in sequence — the deny check, next_hop_self, the eBGP branch, and what the function actually returns.

Two peer types, because export isn't uniform

class PeerType(str, Enum):
    EBGP = "ebgp"
    IBGP = "ibgp"

That's the whole enum. It exists because the export path branches on it — eBGP export and iBGP export do not look the same, and the code makes that structural rather than incidental. If you've only ever configured BGP through a vendor CLI, this is worth pausing on: the peer type isn't a label on a neighbor statement, it's a branch condition in the outbound path.

The policy is data, not behavior

@dataclass(frozen=True)
class ExportPolicy:
    local_as: int
    next_hop_self: bool = False
    deny_prefixes: tuple[str, ...] = ()
    extra_prepend_count: int = 0

Four fields, each of which corresponds to something you've probably typed into a router config without thinking about it as a function parameter:

  • deny_prefixes — the set of prefixes that never leave, regardless of what's installed locally.
  • next_hop_self — whether outbound state gets a rewritten next-hop.
  • local_as — what gets prepended on eBGP export.
  • extra_prepend_count — deliberate path inflation, the traffic-engineering knob.

The dataclass is frozen=True, and that matters for how you read the next function. Policy is immutable input. The path is immutable input. Everything that happens in prepare_export() produces a new object.

Reading prepare_export()

Here's the function in full:

def prepare_export(
    path: PathCandidate,
    peer_type: PeerType,
    policy: ExportPolicy,
) -> PathCandidate | None:
    if path.prefix in policy.deny_prefixes:
        return None

    exported = path
    if policy.next_hop_self:
        exported = replace(exported, next_hop="self")

    if peer_type is PeerType.EBGP:
        prepend_count = 1 + policy.extra_prepend_count
        exported = replace(
            exported,
            as_path=((policy.local_as,) * prepend_count) + exported.as_path,
        )

    return exported

Look at the signature first. It takes a PathCandidate and returns PathCandidate | None. That None in the return type is doing a lot of conceptual work — it's the type system telling you that "there is a best path" and "the peer hears about it" are two separate propositions.

The deny check comes first

    if path.prefix in policy.deny_prefixes:
        return None

This is the first statement in the function, before any transformation happens, and it returns None rather than raising, logging, or removing anything.

Notice what it does not touch: path is unchanged, and nothing anywhere in this function reaches back into Loc-RIB. A denied prefix is still installed. It's still the best path. Your forwarding table still uses it. The peer simply never hears about it.

That asymmetry is the single most useful thing in this module. "The route isn't in my table" and "the route isn't in my neighbor's table" are different failures with different fixes, and this early return is where the difference is made concrete.

Next-hop rewrite is a state divergence

    exported = path
    if policy.next_hop_self:
        exported = replace(exported, next_hop="self")

exported = path starts the transform chain by aliasing the input, then every step uses dataclasses.replace to build a new frozen instance. Read this as an accumulator: exported is the outbound view under construction, and path stays pristine as the locally installed view.

next-hop-self is the canonical case where those two views diverge. The local route points at whatever next-hop it learned. The advertised route points at you. Same prefix, same best-path decision, different attribute on the wire.

Note: next_hop="self" here is a symbolic placeholder, not a literal forwarding address rewrite. The toy model is showing you that outbound state can differ from local state and where that divergence is implemented — not the address arithmetic a real implementation performs.

The eBGP branch transforms the path

    if peer_type is PeerType.EBGP:
        prepend_count = 1 + policy.extra_prepend_count
        exported = replace(
            exported,
            as_path=((policy.local_as,) * prepend_count) + exported.as_path,
        )

Two things are packed into prepend_count = 1 + policy.extra_prepend_count. The 1 is the baseline: crossing an AS boundary means your AS number goes on the front of the path. The extra_prepend_count is deliberate — you're making the path look longer than it is, because path length is an input to somebody else's best-path selection and you'd rather they picked a different entrance.

The tuple arithmetic makes the semantics unambiguous. (policy.local_as,) * prepend_count builds a run of repeated AS numbers, and + exported.as_path puts them at the front. AS path is read left to right as most-recent-first; prepending is how you get there.

And the iBGP case? There's no else. When peer_type is IBGP, this block is skipped entirely and as_path passes through untouched — which is exactly right, because no AS boundary was crossed. The absence of code is the protocol behavior here.

Same shape, different protocol

Once you've seen this structure, you start noticing it elsewhere. The pattern is: a local decision produces internal state, and a separate policy step decides what portion of that state is externally visible, in what form.

DNS does this with authoritative zone data versus what a resolver actually returns to a client. TLS does it with the full certificate chain a server holds versus what it chooses to present in a given handshake. In every case the mistake is the same — assuming the outbound view is a read of the internal view rather than a transform of it. Debugging gets much faster when you stop asking "why doesn't my peer see it?" and start asking "which function decides what my peer sees, and what are its inputs?"

Run it

The walkthrough is executable:

PYTHONPATH=src python3 examples/bgp/session_08_walkthrough.py

It runs three cases against the same input path: eBGP export, iBGP export with next-hop-self enabled, and a deny case where the route stays local but never gets advertised. Watching the same PathCandidate come out three different ways — and once as None — is more convincing than reading about it.

Source: examples/bgp/session_08_walkthrough.py

Toy model boundary

This is a teaching model, and it's worth being precise about what it isn't.

next_hop="self" is symbolic. A real implementation rewrites the NEXT_HOP attribute to an actual address — typically a local interface or loopback address chosen per session. The string "self" here is a marker that a rewrite happened, not a representation of the value.

The eBGP branch compresses several behaviors into one. Real eBGP export does more than prepend the local AS. It handles NEXT_HOP semantics that vary by topology and session type, MED treatment across AS boundaries, LOCAL_PREF stripping (a well-known attribute that must not cross an eBGP boundary), community handling, and more. This module collapses all of that into a single visible transformation so the shape — outbound state is transformed, not copied — is unmissable. Don't read the single prepend as a claim of completeness.

next_hop_self is presented as a boolean knob. In production, NEXT_HOP behavior on export depends on peer type, whether the route was learned from an eBGP or iBGP peer, whether it's a directly connected route, route-reflector configuration, and per-vendor defaults. Treating it as one flag is a teaching simplification, not a claim about default behavior in every real eBGP export case.

There is no route-reflector logic here. iBGP export in real deployments involves reflection rules, cluster lists, and originator IDs. This model's iBGP path is simply "skip the prepend."

Policy is a single function with four fields. Real export policy is a chain of route-maps and filter lists with match/set clauses evaluated in sequence, capable of modifying essentially any attribute. prepare_export() is one function with fixed branches so you can see the control flow rather than a policy DSL.

The boundary is the point, not an apology for it. The model is deliberately small enough that the branching structure is visible, and once you've internalized that structure the real implementations read as elaborations rather than mysteries.

Check yourself

Go back to the source and answer these without looking anywhere else. If you can't, you haven't read it closely enough yet.

  1. Which single condition causes prepare_export() to return None — and what does the function do to the locally installed path in that case?
  2. Under what circumstances does next_hop become "self", and does the peer type affect that at all?
  3. Trace the value of exported.as_path for an iBGP peer versus an eBGP peer with extra_prepend_count=2. Which line is responsible for the difference, and which line is responsible for the absence of a difference?

If you can articulate why "installed route" and "exported route" are different objects in practice — and point at the exact lines that make them different — the module has done its job.

Further reading

Read more