A Linux host's routing table looks trivial until the day it is not. Two default routes, a container bridge, a VPN that installed something you did not ask for, and suddenly traffic is leaving the wrong interface with the wrong source address and nobody can explain why. The table is not the problem. The problem is that most engineers only ever read it, and reading is the smallest part of what ip route does.
This article covers the routing table on Linux as a routing engineer would want it explained: how a route is actually keyed, what every field in a route line means, the difference between show and get, adding and removing static routes, the replace behavior that surprises everybody the first time, blackhole and unreachable routes, and multiple tables with policy rules. Every command was run against a real Cisco Modeling Labs topology from a Debian 13 host, and the router side is shown where it clarifies what the host is doing. It is part of the Linux networking commands cluster, and it assumes you have met the ip command already.
The table you start with
The host here has two interfaces: ens192 on the management network with a DHCP lease, and ens224 bridged into the lab.
j@llmbits:~$ ip route show
default via 192.168.88.1 dev ens192 proto dhcp src 192.168.88.156 metric 101
10.77.0.0/24 dev ens224 proto kernel scope link src 10.77.0.100
10.77.0.0/16 via 10.77.0.1 dev ens224
192.168.88.0/24 dev ens192 proto kernel scope link src 192.168.88.156 metric 101Four routes, four different stories, and the fields tell you which is which.
The destination comes first. default is shorthand for 0.0.0.0/0, and you can type either.
via gives the next hop. Its absence is meaningful: 10.77.0.0/24 dev ens224 has no via because that prefix is directly connected, so the destination is reachable on the wire and there is nothing to hand off to. This is the same distinction IOS draws between C and S routes, just expressed by omission instead of a letter.
proto records who installed the route. kernel means the kernel derived it automatically from an interface address. dhcp means a lease supplied it. static means a human or a config file added it. A routing daemon uses its own value (bird, bgp, ospf, zebra). When you are trying to work out which piece of software put a mystery route in your table, this field is the first place to look, and ip route show proto static filters to just the ones somebody typed.
scope describes how far the destination is. scope link means "on this segment". The default is global, which is why it is not printed on the routes that have a next hop.
src is the preferred source address, and it is the field that quietly causes the most confusion on multihomed hosts. It sets the source IP the kernel puts on locally originated packets that use this route. If your monitoring traffic is arriving at the collector from an address the firewall does not expect, this is usually why.
metric breaks ties. Where two routes have the same prefix length, the lower metric wins. Note that the two routes learned from DHCP carry metric 101 while the hand-added lab route carries no metric at all, which means metric 0.
How a lookup actually works
Linux does longest-prefix match, the same as any router. Notice that this table contains both 10.77.0.0/24 (connected, on ens224) and 10.77.0.0/16 (via the lab gateway). Those overlap, and the /24 is more specific, so anything inside 10.77.0.x is treated as on-link and everything else in 10.77.x.x goes to the router. That is deliberate here, and it is exactly the pattern you get on any host that has a summary route pointed at a gateway plus a connected subnet inside it.
Rather than reasoning about that by eye, ask the kernel:
j@llmbits:~$ ip route get 10.77.2.10
10.77.2.10 via 10.77.0.1 dev ens224 src 10.77.0.100 uid 1000
cache
j@llmbits:~$ ip route get 8.8.8.8
8.8.8.8 via 192.168.88.1 dev ens192 src 192.168.88.156 uid 1000
cacheip route get is the Linux equivalent of show ip route 10.77.2.10 on IOS, and it is the single most useful command in this article. It runs a real lookup through the real forwarding logic: policy rules, longest-prefix match, source address selection, all of it. It tells you the next hop, the exit device and the source address the kernel would use. show tells you what is configured; get tells you what will happen.
The cache line at the end is a leftover from the days when Linux kept a real route cache. Modern kernels do not, and it is now just a marker on the output.
The tables you did not know you had
ip route show is not showing you the routing table. It is showing you one of at least three, and defaulting to the one called main:
j@llmbits:~$ ip route show table main
default via 192.168.88.1 dev ens192 proto dhcp src 192.168.88.156 metric 101
10.77.0.0/24 dev ens224 proto kernel scope link src 10.77.0.100
10.77.0.0/16 via 10.77.0.1 dev ens224
192.168.88.0/24 dev ens192 proto kernel scope link src 192.168.88.156 metric 101Identical, because main is the default. The interesting one is local, which the kernel maintains and which you will almost never see unless you go looking:
j@llmbits:~$ ip route show table local
local 10.77.0.100 dev ens224 proto kernel scope host src 10.77.0.100
broadcast 10.77.0.255 dev ens224 proto kernel scope link src 10.77.0.100
local 127.0.0.0/8 dev lo proto kernel scope host src 127.0.0.1
local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1
broadcast 127.255.255.255 dev lo proto kernel scope link src 127.0.0.1
local 192.168.88.156 dev ens192 proto kernel scope host src 192.168.88.156
broadcast 192.168.88.255 dev ens192 proto kernel scope link src 192.168.88.156This is how the kernel knows which addresses belong to itself. Every address you configure gets a local /32 in here automatically, along with the subnet's broadcast address. When a packet arrives, the local table is consulted first, and a hit means "deliver this upward, do not forward it". If you have ever wondered how a host decides a packet is for it, this table is the answer, and you can see the reason it needs no configuration.
What decides which table gets consulted, and in what order, is the rule set:
j@llmbits:~$ ip rule show
0: from all lookup local
32766: from all lookup main
32767: from all lookup defaultThree rules, evaluated in ascending priority order, first match wins. Rule 0 checks local, so packets addressed to this host are caught immediately. Rule 32766 checks main, which is your normal routing table. Rule 32767 checks default, which is almost always empty. Everything in policy routing is done by inserting rules between those numbers.
Adding static routes
The syntax mirrors what you already know, with the device optional when the next hop is unambiguous:
j@llmbits:~$ sudo ip route add 10.99.99.0/24 via 10.77.0.1 dev ens224 metric 50
j@llmbits:~$ sudo ip route add blackhole 172.31.0.0/16
j@llmbits:~$ ip route show
default via 192.168.88.1 dev ens192 proto dhcp src 192.168.88.156 metric 101
10.77.0.0/24 dev ens224 proto kernel scope link src 10.77.0.100
10.77.0.0/16 via 10.77.0.1 dev ens224
10.99.99.0/24 via 10.77.0.1 dev ens224 metric 50
blackhole 172.31.0.0/16
192.168.88.0/24 dev ens192 proto kernel scope link src 192.168.88.156 metric 101Both routes went in. The interesting one is the blackhole, because its behavior is not what most people expect.
Blackhole is not a silent drop
On a Cisco box, ip route 172.31.0.0 255.255.0.0 Null0 discards traffic quietly. On Linux, a blackhole route makes the lookup itself fail, and that failure is returned to the calling application immediately:
j@llmbits:~$ ip route get 172.31.5.5
RTNETLINK answers: Invalid argument
j@llmbits:~$ ping -c 2 -W 2 172.31.5.5
ping: connect: Invalid argumentping did not time out. It did not even send a packet. The connect() call failed at the socket layer because the kernel refused to give it a route, and it returned instantly. That is a genuinely useful property: an application hitting a blackhole route fails fast instead of hanging for a connection timeout. It is also a genuinely confusing one if you were expecting silence and got an immediate error from a tool you thought was on the network.
The related types are worth knowing together:
blackholeunreachableprohibitthrowThe replace gotcha: metric is part of the key
This one costs people an afternoon. ip route replace is documented as "change a route, or add it if it does not exist", which sounds like it will overwrite what is there. Watch:
j@llmbits:~$ sudo ip route replace 10.99.99.0/24 via 10.77.0.1 dev ens224 metric 20
j@llmbits:~$ ip route show 10.99.99.0/24
10.99.99.0/24 via 10.77.0.1 dev ens224 metric 20
10.99.99.0/24 via 10.77.0.1 dev ens224 metric 50Two routes to the same prefix, via the same next hop, out the same interface. Nothing was replaced.
The reason is that a route is not keyed on destination alone. The key includes the metric, the table, the TOS and the routing protocol. Changing the metric produced a route the kernel considered new, so replace behaved as add. The old one is still there, and it is still reachable, and the only visible symptom is that your table has grown a duplicate you did not intend.
Traffic still works, because the lower metric wins the tie-break. But now delete the route the way you normally would and you will delete the wrong one, or believe you deleted it when you did not. To remove a specific instance you have to name the metric:
j@llmbits:~$ sudo ip route del 10.99.99.0/24 metric 50
j@llmbits:~$ ip route show 10.99.99.0/24
10.99.99.0/24 via 10.77.0.1 dev ens224 metric 20The practical rule: when you use replace, keep every key field identical to the route you intend to overwrite, or you are adding rather than replacing. When you use del, always check with ip route show <prefix> afterward instead of assuming.
Policy routing: a second table and a rule
Everything above lives in one table. Policy routing is what you use when the answer depends on more than the destination: source address, incoming interface, firewall mark. This is the Linux analogue of PBR, and it is considerably easier to work with than the IOS route-map version.
Build a table, then a rule that points at it:
j@llmbits:~$ sudo ip route add default via 10.77.0.1 dev ens224 table 100
j@llmbits:~$ sudo ip rule add from 10.77.0.100 lookup 100 priority 100The table number is arbitrary and needs no prior declaration; referencing it creates it. You can give it a name by adding a line to /etc/iproute2/rt_tables, which is worth doing for anything permanent so that the next person does not have to guess what table 100 was for.
j@llmbits:~$ ip rule show
0: from all lookup local
100: from 10.77.0.100 lookup 100
32766: from all lookup main
32767: from all lookup default
j@llmbits:~$ ip route show table 100
default via 10.77.0.1 dev ens224Priority 100 puts the new rule after local and well before main. Any packet whose source address is 10.77.0.100 now consults table 100 first, finds a default route pointing into the lab, and uses it, regardless of what main says. This is the standard fix for the multihomed asymmetric routing problem: traffic that arrived on one interface should reply out the same interface, and a source-based rule per interface enforces that.
Rule selectors go well beyond source address. from, to, iif, oif, fwmark, ipproto, sport and dport are all available, and fwmark combined with iptables or nftables packet marking is how most non-trivial policy routing gets built.
To see every route in every table at once, which is the view you want when something is behaving inexplicably:
j@llmbits:~$ ip -4 route show table all
default via 10.77.0.1 dev ens224 table 100
default via 192.168.88.1 dev ens192 proto dhcp src 192.168.88.156 metric 101
10.77.0.0/24 dev ens224 proto kernel scope link src 10.77.0.100
10.77.0.0/16 via 10.77.0.1 dev ens224
10.99.99.0/24 via 10.77.0.1 dev ens224 metric 20
blackhole 172.31.0.0/16
192.168.88.0/24 dev ens192 proto kernel scope link src 192.168.88.156 metric 101
local 10.77.0.100 dev ens224 table local proto kernel scope host src 10.77.0.100
broadcast 10.77.0.255 dev ens224 table local proto kernel scope link src 10.77.0.100
local 127.0.0.0/8 dev lo table local proto kernel scope host src 127.0.0.1
local 127.0.0.1 dev lo table local proto kernel scope host src 127.0.0.1
broadcast 127.255.255.255 dev lo table local proto kernel scope link src 127.0.0.1
local 192.168.88.156 dev ens192 table local proto kernel scope host src 192.168.88.156
broadcast 192.168.88.255 dev ens192 table local proto kernel scope link src 192.168.88.156Routes with no table qualifier are in main. Everything else is labeled. Run this first the next time a host is sending traffic somewhere you cannot account for, because a stray rule pointing at a table you forgot about is invisible in the default view.
Cleaning up
j@llmbits:~$ sudo ip rule del priority 100
j@llmbits:~$ sudo ip route flush table 100
j@llmbits:~$ sudo ip route del blackhole 172.31.0.0/16
j@llmbits:~$ sudo ip route del 10.99.99.0/24
j@llmbits:~$ ip route show
default via 192.168.88.1 dev ens192 proto dhcp src 192.168.88.156 metric 101
10.77.0.0/24 dev ens224 proto kernel scope link src 10.77.0.100
10.77.0.0/16 via 10.77.0.1 dev ens224
192.168.88.0/24 dev ens192 proto kernel scope link src 192.168.88.156 metric 101Deleting a rule by priority is safer than deleting it by selector, since the selector may match more than one rule. ip route flush table 100 empties a table without touching anything else, and a table with no routes in it effectively stops existing.
As with everything ip does, none of this survives a reboot. Persisting a static route means putting it where the interface manager will reapply it: an ipv4.routes property on an nmcli connection, a [Route] section in a systemd-networkd unit, or a post-up line in /etc/network/interfaces. Use ip route to find the route that works, then write that route into the right place.
The router side of the same path
The lab this host is plugged into runs OSPF across three routers, and it is worth seeing both tables together, because the concepts line up exactly even though the syntax does not:
R1#show ip route
1.0.0.0/32 is subnetted, 1 subnets
C 1.1.1.1 is directly connected, Loopback0
2.0.0.0/32 is subnetted, 1 subnets
O 2.2.2.2 [110/11] via 10.77.12.2, 00:03:28, Ethernet0/1
3.0.0.0/32 is subnetted, 1 subnets
O 3.3.3.3 [110/21] via 10.77.12.2, 00:03:18, Ethernet0/1
10.0.0.0/8 is variably subnetted, 7 subnets, 2 masks
C 10.77.0.0/24 is directly connected, Ethernet0/0
L 10.77.0.1/32 is directly connected, Ethernet0/0
O 10.77.2.0/24 [110/20] via 10.77.12.2, 00:03:28, Ethernet0/1
O 10.77.3.0/24 [110/30] via 10.77.12.2, 00:03:18, Ethernet0/1
C 10.77.12.0/24 is directly connected, Ethernet0/1
L 10.77.12.1/32 is directly connected, Ethernet0/1
O 10.77.23.0/24 [110/20] via 10.77.12.2, 00:03:22, Ethernet0/1The L entries on IOS (a /32 for the router's own interface address) are exactly the local table on Linux, just displayed inline instead of in a separate table. The C entries are the proto kernel scope link routes. The [110/20] pair is administrative distance and metric, where Linux has only metric because it has no concept of comparing across protocols in the kernel. Everything else maps one to one. If you want the routing protocol side of this, the OSPF cluster covers what those O routes went through to get there.
And the path itself, proven from the host:
j@llmbits:~$ traceroute -n 10.77.3.10
traceroute to 10.77.3.10 (10.77.3.10), 30 hops max, 60 byte packets
1 10.77.0.1 4.452 ms 4.379 ms 4.437 ms
2 10.77.12.2 4.686 ms 6.932 ms 6.960 ms
3 10.77.23.3 7.029 ms 7.022 ms 8.543 ms
4 10.77.3.10 8.506 ms 10.428 ms 10.354 msFAQ
Why does ip route replace add a second route instead of replacing?
Because the metric is part of the route key, along with the table, the TOS and the protocol. If any of those differ from the existing route, the kernel treats yours as a new route and adds it. Keep every key field identical when you mean to replace, and verify with ip route show <prefix> afterward.
What is the difference between blackhole and unreachable?
A blackhole route fails the lookup with EINVAL and sends nothing, so the local application gets an instant error and no ICMP is generated for forwarded traffic. An unreachable route fails with EHOSTUNREACH and does generate ICMP host unreachable for traffic you were forwarding. Use blackhole when you want the traffic to disappear cheaply, unreachable when the sender deserves to be told.
How do I make a static route survive a reboot?
ip route add writes to the running kernel only. Persistence belongs to whatever manages your interfaces: nmcli connection modify <name> +ipv4.routes "10.99.99.0/24 10.77.0.1" under NetworkManager, a [Route] section under systemd-networkd, or netplan on Ubuntu. Test with ip route, then write the working route into the manager.
Do I need policy routing, or just a more specific route?
If the decision depends only on the destination, add a more specific route and stop. Policy routing is for when the decision depends on something else: which source address originated the packet, which interface it arrived on, or a firewall mark. Reach for it for asymmetric multihoming and for steering marked traffic, not for ordinary destination-based forwarding.
Why does my traffic leave with the wrong source address?
Look at the src field on the route being used, which ip route get <destination> will show you directly. If no src is set, the kernel picks the primary address on the outgoing interface. Pin it by adding src <address> to the route.
Key takeaways
ip route get <destination>runs a real lookup and reports the actual decision. It is the command that answers "where will this go", andshowis not.- The
protofield identifies who installed each route, which is how you find out which daemon or lease is responsible for one you did not add. srcsets the source address for locally originated traffic and is the usual cause of packets arriving from an unexpected IP on a multihomed host.- You always have at least three tables.
localis how the kernel recognizes its own addresses,mainis what you normally see, andip rule showis the order they are consulted in. - A route is keyed on destination plus metric, table, TOS and protocol.
replacewith a different metric silently adds instead of replacing. blackholemakes the lookup fail immediately rather than dropping silently, so applications get an instant error instead of a timeout.- Policy routing is a table plus a rule. Source-based rules are the standard fix for asymmetric routing on multihomed hosts.
ip -4 route show table allis the first command to run when traffic is going somewhere you cannot explain.
Next, the neighbor table that turns those next hops into frames: ARP on Linux: ip neigh, arp and neighbor states. For the full object model behind this command, go back to the Linux ip command guide. The whole cluster is indexed on the Linux networking commands guide.