Every engineer knows what ARP does. Far fewer know that Linux does not keep a simple ARP cache at all: it keeps a neighbor table with a six-state machine per entry, shared between IPv4 ARP and IPv6 neighbor discovery, with its own timers, garbage collector and failure handling. arp -n hides all of it behind a single flag column, which is why the tool most people still reach for is the one least able to help during an incident.
This article walks that state machine live. Every state below was captured by driving the table through it on a Debian 13 host wired into a Cisco Modeling Labs topology: flush it, resolve a neighbor, wait for it to go stale, probe an address nobody answers for, and watch the entry fail. Along the way, the static entries, the tunables, the legacy arp equivalents and the router's view of the same adjacency. It is part of the Linux networking commands cluster.
One table, two protocols
Start with the whole thing:
j@llmbits:~$ ip neigh show
192.168.88.124 dev ens192 lladdr 34:9f:7b:7f:ca:ee STALE
10.77.0.1 dev ens224 lladdr aa:bb:cc:00:04:00 STALE
192.168.88.1 dev ens192 lladdr 98:ba:5f:11:1b:3a REACHABLE
192.168.88.125 dev ens192 lladdr 58:ef:68:e7:26:1a REACHABLE
fe80::18d8:e4e1:ba78:179e dev ens192 lladdr 1c:b3:c9:02:f4:2f router STALE
fe80::1072:e4df:6063:2046 dev ens192 lladdr c4:f7:c1:09:40:9d router STALE
fe80::c1f:b024:94ab:44c3 dev ens192 lladdr f4:34:f0:69:8a:76 router STALEIPv4 and IPv6 entries in one listing. That is not a display convenience, it is the architecture: ARP and IPv6 neighbor discovery are different protocols on the wire but the same subsystem in the kernel, with the same states, the same timers and the same table. The router flag on the IPv6 entries is the one visible difference, set when the neighbor advertised itself as a router in an NA. Learn the state machine once and you know both.
Each line is destination address, interface, link-layer address, state. The state is the part arp cannot show you and the part that matters.
The state machine, walked live
Six states, and the fastest way to understand them is to drive an entry through each one.
Empty to REACHABLE
j@llmbits:~$ sudo ip neigh flush dev ens224
j@llmbits:~$ ip neigh show dev ens224
j@llmbits:~$Nothing. Now send a single packet to the gateway and look again:
j@llmbits:~$ ping -c 1 10.77.0.1 >/dev/null; ip neigh show dev ens224
10.77.0.1 lladdr aa:bb:cc:00:04:00 REACHABLEREACHABLE is the strongest statement the kernel makes. It means positive confirmation that this neighbor is present and this mapping is correct, obtained within the reachability window. Confirmation does not have to come from an ARP exchange; an established TCP connection making forward progress counts, which is why a busy neighbor can sit in REACHABLE indefinitely without a single ARP packet crossing the wire.
REACHABLE to STALE
j@llmbits:~$ sleep 35; ip neigh show dev ens224
10.77.0.1 lladdr aa:bb:cc:00:04:00 STALEThirty five seconds of silence and the entry aged out of REACHABLE. The window is set per interface:
j@llmbits:~$ sysctl net.ipv4.neigh.ens224.base_reachable_time_ms
net.ipv4.neigh.ens224.base_reachable_time_ms = 30000Thirty seconds, which the kernel then randomizes between half and one and a half times that value so that a rack of hosts does not synchronize its ARP traffic. Thirty five seconds is reliably past the top of that range.
Here is the important part, and the thing that trips people up: STALE is not a problem. The mapping is still there and still used. The kernel simply has not verified it recently and will not spend a packet doing so until something actually needs to send to that neighbor. An idle host's table is almost entirely STALE, and that is healthy. If you see STALE next to an address you cannot reach, the neighbor table is not your fault line.
DELAY and PROBE
These two are transient by design and you will struggle to catch them. When traffic needs to go to a STALE neighbor, the entry moves to DELAY and the kernel waits, typically five seconds, to see whether an upper layer will confirm reachability on its own. If a TCP ACK comes back in that window, the entry jumps straight to REACHABLE and no ARP is sent. That is the optimization that keeps ARP traffic on a busy network far lower than you would expect.
Only if nothing confirms it does the entry move to PROBE and the kernel start sending unicast ARP requests to the cached MAC. Three by default, one second apart. Success returns it to REACHABLE; failure sends it to FAILED.
Catching DELAY or PROBE in a show means winning a race against a five second timer that usually resolves in milliseconds. Their absence from your output is the system working, not a gap in the capture.
INCOMPLETE
Now ask for an address nobody owns:
j@llmbits:~$ ping -c 1 -W 2 10.77.0.55
--- 10.77.0.55 ping statistics ---
1 packets transmitted, 0 received, 100% packet loss, time 0ms
j@llmbits:~$ ip neigh show dev ens224
10.77.0.1 lladdr aa:bb:cc:00:04:00 STALE
10.77.0.55 INCOMPLETEINCOMPLETE means a broadcast ARP request has gone out and no reply has come back yet. Note there is no lladdr at all, because there is nothing to record. This is the state that says "I am asking and nobody is answering", and it is the correct read for an address that is not on the segment, a host that is down, or a VLAN mismatch that means your broadcast is not landing where you think it is.
FAILED
j@llmbits:~$ sleep 20; ip neigh show dev ens224
10.77.0.1 lladdr aa:bb:cc:00:04:00 STALE
10.77.0.55 FAILEDThe probes ran out. FAILED is a negative cache entry: the kernel remembers that resolution did not work so it does not restart the whole broadcast cycle for every packet an application throws at that address. It will retry, but on a backoff rather than immediately.
Two FAILED entries appearing for hosts that should be up is a much stronger signal than a table full of STALE. It means ARP requests are going out and nothing is coming back, which points at layer 2: the wrong VLAN, a blocked port, an ACL on a switch, or a host that is genuinely gone.
The counters behind the states
-s exposes the timers the state machine is running on:
j@llmbits:~$ ip -s neigh show dev ens224
10.77.0.1 lladdr aa:bb:cc:00:04:00 used 37/37/3probes 4 STALE
10.77.0.55 ref 1 used 2/62/2probes 5 INCOMPLETEThe used triplet is seconds since the entry was last used, last updated and last confirmed. For the gateway all three cluster around 37, which is consistent with one ping 37 seconds ago and nothing since. probes 4 counts probes sent on this entry. ref 1 on the incomplete entry is the reference count, one socket still waiting on a resolution that is never coming.
When you are chasing an intermittent reachability problem, the gap between "last used" and "last confirmed" is the number that tells you whether the kernel is actually validating the neighbor or just leaning on a cached mapping.
Filtering by state
nud (neighbor unreachability detection) filters the table by state, and this is what makes the neighbor table useful at scale:
j@llmbits:~$ ip neigh show nud reachable
192.168.88.125 dev ens192 lladdr 58:ef:68:e7:26:1a REACHABLEOn a host with hundreds of entries, ip neigh show nud failed is a one-line health check that goes straight to what is broken. ip neigh show nud incomplete catches resolution in progress. ip neigh show nud permanent lists your static entries. There is no arp equivalent for any of these, because arp does not know the states exist.
Static entries
j@llmbits:~$ sudo ip neigh add 10.77.0.77 lladdr 00:11:22:33:44:55 dev ens224 nud permanent
j@llmbits:~$ ip neigh show dev ens224
10.77.0.1 lladdr aa:bb:cc:00:04:00 STALE
10.77.0.77 lladdr 00:11:22:33:44:55 PERMANENT
j@llmbits:~$ ip neigh show nud permanent
10.77.0.77 dev ens224 lladdr 00:11:22:33:44:55 PERMANENTPERMANENT is the seventh state and it sits outside the machine entirely. No timers, no probes, no garbage collection. The kernel will use this mapping forever and never verify it, which is exactly as dangerous as it sounds. If the real host's NIC is replaced, this entry keeps pointing at a MAC that no longer exists and no amount of waiting will fix it.
There are two legitimate uses. One is pinning a critical next hop against ARP spoofing, and even then you are trading one failure mode for another. The other is testing, where you want to force a specific mapping and observe what happens. Outside those cases, a permanent entry is technical debt with a MAC address in it.
j@llmbits:~$ sudo ip neigh del 10.77.0.77 dev ens224
j@llmbits:~$ ip neigh show dev ens224
10.77.0.1 lladdr aa:bb:cc:00:04:00 STALENote that ip neigh del on a dynamically learned entry does not remove it permanently. It invalidates it, and the next packet re-resolves. To actually clear a set of entries use ip neigh flush, which takes the same dev and nud filters as show.
The tunables, and the one that bites
j@llmbits:~$ sysctl net.ipv4.neigh.ens224.base_reachable_time_ms net.ipv4.neigh.ens224.gc_stale_time \
net.ipv4.neigh.default.gc_thresh1 net.ipv4.neigh.default.gc_thresh2 net.ipv4.neigh.default.gc_thresh3
net.ipv4.neigh.ens224.base_reachable_time_ms = 30000
net.ipv4.neigh.ens224.gc_stale_time = 60
net.ipv4.neigh.default.gc_thresh1 = 128
net.ipv4.neigh.default.gc_thresh2 = 512
net.ipv4.neigh.default.gc_thresh3 = 1024
base_reachable_time_msHow long a confirmed entry stays REACHABLE, randomized 0.5x to 1.5x. Default 30000.
gc_stale_timeSeconds a STALE entry survives with no use before the collector may remove it. Default 60.
gc_thresh1Below this entry count, garbage collection never runs at all. Default 128.
gc_thresh2Soft ceiling. Above it the collector runs aggressively after a five second grace period. Default 512.
gc_thresh3Hard ceiling. New entries are refused. This is where "neighbour table overflow" comes from. Default 1024.
gc_thresh3 is the one that shows up in production. A default of 1024 is generous for a server and completely inadequate for a router, a Kubernetes node, or anything on a large flat subnet. When the table hits the hard ceiling the kernel logs neighbour table overflow and starts refusing new entries, which presents as intermittent, apparently random reachability failures to hosts that are plainly up. If you see that message, raise all three thresholds together rather than just the top one, since the collector needs headroom between them to work.
The legacy view of the same table
arp reads the same kernel table and shows you a fraction of it:
j@llmbits:~$ arp -an
? (192.168.88.124) at 34:9f:7b:7f:ca:ee [ether] on ens192
? (10.77.0.1) at aa:bb:cc:00:04:00 [ether] on ens224
? (192.168.88.1) at 98:ba:5f:11:1b:3a [ether] on ens192
? (192.168.88.125) at 58:ef:68:e7:26:1a [ether] on ens192
? (10.77.0.55) at <incomplete> on ens224
j@llmbits:~$ arp -n
Address HWtype HWaddress Flags Mask Iface
192.168.88.124 ether 34:9f:7b:7f:ca:ee C ens192
10.77.0.1 ether aa:bb:cc:00:04:00 C ens224
192.168.88.1 ether 98:ba:5f:11:1b:3a C ens192
192.168.88.125 ether 58:ef:68:e7:26:1a C ens192
10.77.0.55 (incomplete) ens224Compare that to ip neigh show at the top of this article, taken from the same table at the same time. Every resolved entry gets the flag C for "complete", regardless of whether the kernel confirmed it two seconds ago or has not verified it in an hour. 192.168.88.1 is REACHABLE and 192.168.88.124 is STALE; arp shows both as C. The IPv6 entries are missing entirely, because arp predates neighbor discovery and has no concept of it.
The flags are worth knowing since you will meet them in old runbooks: C is complete, M is permanent (manually added), and P is published, meaning this host answers ARP on behalf of that address. The wider net-tools to iproute2 map is in ifconfig, route and netstat.
IPv6 neighbor discovery is the same table
j@llmbits:~$ ip -6 neigh show dev ens192
fe80::18d8:e4e1:ba78:179e lladdr 1c:b3:c9:02:f4:2f router STALE
fe80::1072:e4df:6063:2046 lladdr c4:f7:c1:09:40:9d router STALE
fe80::c1f:b024:94ab:44c3 lladdr f4:34:f0:69:8a:76 router STALESame states, same commands, same tunables under net.ipv6.neigh.*. The protocol differences are on the wire rather than in the table: neighbor solicitation and advertisement instead of ARP request and reply, ICMPv6 instead of a separate ethertype, and solicited-node multicast instead of broadcast, so a neighbor solicitation only reaches hosts whose address ends in the same 24 bits rather than every NIC on the segment.
The router flag is set from the R bit in a neighbor advertisement, and it is how the host knows which of its neighbors can forward for it. Three routers on this segment, all discovered without configuration. For the protocol side, see the IPv6 cluster.
The router side of the same adjacency
The host's default gateway into the lab is a Cisco router, and it has its own view of the relationship:
R1#show ip arp
Protocol Address Age (min) Hardware Addr Type Interface
Internet 10.77.0.1 - aabb.cc00.0400 ARPA Ethernet0/0
Internet 10.77.0.100 0 000c.29b1.cc47 ARPA Ethernet0/0
Internet 10.77.12.1 - aabb.cc00.0410 ARPA Ethernet0/1
Internet 10.77.12.2 4 aabb.cc00.0300 ARPA Ethernet0/1000c.29b1.cc47 is the Linux host's ens224 MAC, which Linux prints as 00:0c:29:b1:cc:47. Same 48 bits, two formatting conventions. The Age (min) column is IOS doing in minutes what Linux does in states, and the dash on the router's own interfaces marks entries that never age.
The structural difference is worth naming. IOS ages ARP entries out on a fixed four hour timer by default and does not track per-entry reachability. Linux tracks reachability continuously and only ages an entry when nothing is using it. That is why a Cisco ARP table can hold a stale mapping for hours after a host moves, while Linux notices within about half a minute of trying to use it. Neither is wrong; they solve for different things.
FAQ
What does STALE mean, and should I worry about it?
It means the mapping is cached but has not been confirmed within the reachability window. It is completely normal and the entry is still used. An idle host's table is mostly STALE. What deserves attention is FAILED, which means probes went out and nothing came back.
Why do I never see DELAY or PROBE in my output?
Because both are transient. DELAY lasts about five seconds and usually resolves in milliseconds when an upper layer confirms reachability, and PROBE is three unicast ARPs one second apart. Catching either in a show means winning a race. Not seeing them is the system working.
What causes "neighbour table overflow" in my kernel log?
The neighbor table hit gc_thresh3, default 1024 entries, and the kernel started refusing new ones. It presents as intermittent failures to hosts that are clearly up. Common on routers, container hosts and large flat subnets. Raise gc_thresh1, gc_thresh2 and gc_thresh3 together so the collector keeps headroom between them.
Should I use static ARP entries to prevent spoofing?
Rarely, and never as your only control. A PERMANENT entry is never verified and never expires, so a NIC replacement or a failover to a different MAC breaks reachability in a way nothing on the host will diagnose for you. Dynamic ARP inspection and DHCP snooping on the switch solve the problem where it belongs. Pinning a single critical next hop is defensible; pinning a subnet is not.
Does ip neigh work for IPv6?
It is the same table and the same command. ip -6 neigh show filters to IPv6, the states are identical, and the tunables live under net.ipv6.neigh.*. The only extra field is the router flag, taken from the R bit in a neighbor advertisement.
Key takeaways
- Linux has a neighbor table, not an ARP cache. IPv4 ARP and IPv6 neighbor discovery share one subsystem, one state machine and one set of commands.
REACHABLEmeans confirmed in the last thirty seconds or so.STALEmeans cached but unverified, and it is normal.INCOMPLETEmeans asking with no answer.FAILEDmeans the probes ran out. Those two are your layer 2 signal, notSTALE.DELAYandPROBEare transient by design, and an upper-layer confirmation duringDELAYskips the ARP entirely.ip neigh show nud failedis a one-line health check thatarphas no equivalent for.PERMANENTentries are never verified and never expire. Use them for a pinned critical next hop or for testing, not as a security control.gc_thresh3defaults to 1024 and is the source of "neighbour table overflow". Raise all three thresholds together.arp -nflattens every resolved entry to the flagCand cannot see IPv6 at all. Useip neigh.
Next, if you are still translating old habits into new commands, ifconfig, route and netstat: mapping legacy net-tools to iproute2 covers the rest of the migration. The command behind this table is documented in full in the Linux ip command guide, and everything in this cluster is indexed on the Linux networking commands guide.