I spent an evening this month migrating my UDM Pro from the legacy firewall to the zone-based firewall and isolating my IoT VLAN from the Default network at the same time. Two changes, one maintenance window, which is exactly the kind of decision you regret about ninety minutes later.
The next morning things were mostly fine. Mostly. One machine on the 192.168.66.0/24 network could ping the gateway, could not consistently reach the Plex VM, and the UniFi client list looked completely normal the whole time. No alerts. No dropped ports. Nothing in the firewall logs that pointed anywhere useful.
So I opened PowerShell on the affected box and ran one command:
Get-NetNeighbor -AddressFamily IPv4
And there was the answer, sitting in plain text. Four entries pointing at MAC addresses belonging to NICs on a Dell r730xd I physically pulled out of the rack months ago, plus a stale entry for a host I had just moved behind the new VLAN boundary. The machine was not confused about routing. It was confused about who its neighbors were, because it was still working from a cache written before I changed the network underneath it.
That is the whole pitch for Get-NetNeighbor. It is a read-only, non-destructive, sub-second command that shows you what your machine actually believes about the layer 2 segment it sits on. When something is broken between "the cable is plugged in" and "the application returned an error," this is where I look first.
Why nobody uses this
Be honest about how you troubleshoot a Windows box. Most of us learned on the same handful of tools and we never really left: ping, ipconfig /all, tracert, pathping, nslookup, netstat -ano, route print, and arp -a when things get weird. They are muscle memory. I can type ipconfig /all | more faster than I can think about whether I need it.
Those tools are not wrong. They are decades old because they work. But they were built to print text at you, and almost all of them answer a question at layer 3 or above.
If somebody has made the jump to native PowerShell, it is usually exactly one cmdlet: Test-NetConnection. Fair enough, it is a good one. It rolls ping, port check, route lookup, and DNS resolution into a single call and hands back an object you can branch on. It has earned the popularity.
But look at what all of these actually tell you:
| Tool | The question it answers | The layer it answers at |
|---|---|---|
ping / Test-Connection |
Does it respond to ICMP? | 3 |
tracert / pathping |
What is the path and where is the loss? | 3 |
nslookup / Resolve-DnsName |
What does the name resolve to? | 7 |
route print / Get-NetRoute |
Which way would traffic leave? | 3 |
netstat / Get-NetTCPConnection |
What sockets are open? | 4 |
Test-NetConnection |
Can I reach this host and port? | 3 and 4 |
Get-NetNeighbor |
Who is actually on my wire, and what is their MAC? | 2 |
Everything in that list except the last row assumes layer 2 is already working. That assumption is invisible right up until it is wrong, and when it is wrong all of those tools give you results that are technically accurate and completely unhelpful. ping times out. tracert dies on the first hop. Test-NetConnection reports failure with no useful detail about why. Every one of them tells you "no," and none of them tells you whether the host is off, on another VLAN, fighting another device for the same IP, or perfectly fine behind a MAC address your machine cached three weeks ago.
arp -a is the classic answer to that gap, and it is still there in Windows 11 and Server 2025, so use it if you like it. But it only shows IPv4, it has no concept of the neighbor state machine beyond "dynamic" and "static," and it hands you a wall of text that you then have to parse if you want to do anything programmatic with it.
Get-NetNeighbor covers IPv4 and IPv6 in one table, exposes the full reachability state, filters on any field natively, works against remote machines over CIM, and gives you objects instead of text. It also lives in the NetTCPIP module, which is the same module Test-NetConnection comes from. If you have used the one, you already have the other installed and you have never run it.
Here is what it does, how to read it, and the specific troubleshooting patterns I use it for.
What the neighbor cache actually is
Every time your machine wants to send a packet to something on the same subnet, it needs the destination's MAC address. For IPv4 it finds that with ARP: broadcast "who has 192.168.66.5," wait for the owner to answer with its MAC. For IPv6 it uses Neighbor Discovery Protocol, which does the same job with ICMPv6 Neighbor Solicitation and Neighbor Advertisement messages over multicast instead of broadcast.
Windows does not keep two separate tables for these. Since the Vista-era network stack rewrite there has been a single unified neighbor cache holding both, and Get-NetNeighbor is the supported way to read it. The IPv4 entries are the ARP cache. The IPv6 entries are the NDP cache. Same table, same cmdlet, one -AddressFamily parameter to separate them.
The single most important thing to understand, and the thing that trips up most people the first time they use this for troubleshooting: the neighbor cache only contains on-link neighbors. Things on your own segment. If a host is on the other side of a router, it will never appear here. Your machine does not need that host's MAC address, it needs the gateway's MAC address, and that is what shows up.
So if you run this looking for a server three subnets over and it is not listed, nothing is wrong. That is correct behavior. What the cache tells you is the health of the local segment, and that turns out to be where a surprising number of "the network is broken" problems actually live.
The first run
Open PowerShell and run it bare:
Get-NetNeighbor
ifIndex IPAddress LinkLayerAddress State PolicyStore
------- --------- ---------------- ----- -----------
14 ff02::16 33-33-00-00-00-16 Permanent ActiveStore
14 ff02::fb 33-33-00-00-00-FB Permanent ActiveStore
14 ff02::1:3 33-33-00-01-00-03 Permanent ActiveStore
14 fe80::1 74-AC-B9-4E-11-02 Reachable ActiveStore
14 224.0.0.22 01-00-5E-00-00-16 Permanent ActiveStore
14 224.0.0.251 01-00-5E-00-00-FB Permanent ActiveStore
14 239.255.255.250 01-00-5E-7F-FF-FA Permanent ActiveStore
14 255.255.255.255 FF-FF-FF-FF-FF-FF Permanent ActiveStore
14 192.168.66.1 74-AC-B9-4E-11-02 Reachable ActiveStore
14 192.168.66.5 BC-24-11-9A-3C-07 Stale ActiveStore
14 192.168.66.42 B8-27-EB-6D-14-88 Stale ActiveStore
14 192.168.66.77 00-00-00-00-00-00 Unreachable ActiveStoreFive columns, and each one earns its place.
ifIndex is the interface index the entry belongs to. This matters more than people expect, because the neighbor cache is per-interface. A laptop with Wi-Fi, a dock, a VPN adapter, WSL, and Hyper-V has five or more interfaces, each with its own view of its own segment. Same IP address can legitimately appear twice under two different interface indexes, and that is often the actual bug.
IPAddress is the neighbor's address, IPv4 or IPv6.
LinkLayerAddress is the MAC. Dash separated, uppercase hex. The two empty-looking cases are not the same thing and it matters: an address of all zeros means resolution was attempted and failed, so the entry has no link-layer address, while a genuinely empty value means the link layer does not use link-layer addresses at all, which is what you see on a loopback or a point-to-point tunnel adapter. All zeros is a signal. Empty on a tunnel is not.
State is where the real information lives. More on this below.
PolicyStore is ActiveStore for the live in-memory cache or PersistentStore for entries that survive a reboot. Almost everything you see day to day is ActiveStore. PersistentStore entries are things somebody deliberately configured.
Notice all the multicast noise at the top. 224.0.0.22 is IGMP, 224.0.0.251 is mDNS, 239.255.255.250 is SSDP, 255.255.255.255 is the broadcast address, and the ff02:: block is the IPv6 equivalent. These are always Permanent and they are always there. They are not devices. Your first instinct when you see a screen full of Permanent entries with 33-33- and 01-00-5E- MAC prefixes is going to be "what are all these," and the answer is "nothing, filter them out."
The state machine, and why "Stale" is not an error
This is the part I wish someone had explained to me clearly the first time, because I wasted real time chasing Stale entries that were completely healthy.
| State | What it means |
|---|---|
Incomplete |
Resolution is in progress. The solicitation went out, no answer yet. |
Reachable |
Confirmed reachable recently, within the randomized reachable-time window. |
Stale |
Was reachable, the timer expired, nobody has checked since. |
Delay |
Traffic was just sent to a stale neighbor. Probing is briefly deferred to let the upper layer confirm. |
Probe |
Actively sending unicast solicitations to verify reachability. |
Unreachable |
The address is unreachable. |
Permanent |
Statically provisioned. Will not expire unless you remove it. |
The lifecycle in practice: an entry starts Incomplete, becomes Reachable when the neighbor answers, drops to Stale when the reachability timer runs out, and then sits in Stale doing nothing at all until your machine has a reason to send traffic there again. At that point it goes Delay, then Probe if the upper layer does not confirm, and then either back to Reachable or on to Unreachable.
That reachability timer is not arbitrary and you can read it:
Get-NetIPInterface -InterfaceIndex 14 -AddressFamily IPv4 |
Select-Object InterfaceAlias, ReachableTime, BaseReachableTime, RetransmitTime, NeighborUnreachabilityDetectionInterfaceAlias : Ethernet
ReachableTime : 32000
BaseReachableTime : 30000
RetransmitTime : 1000
NeighborUnreachabilityDetection : EnabledThe property names carry no unit suffix even though the values are milliseconds. There is no ReachableTimeMs. That matters more than a naming quibble, because Select-Object invents an empty property rather than complaining when you name one that does not exist, so getting it wrong hands you a blank column instead of an error.
BaseReachableTime defaults to 30000, and ReachableTime is a randomized value derived from it so that a room full of machines do not synchronize their probes. Windows picks a random factor between 0.5 and 1.5 times the base, which puts the actual window somewhere between 15 and 45 seconds. That is why it will be a different value on the next interface you check.
So the practical meaning of Reachable is "confirmed within the last 15 to 45 seconds," not "reachable right now."
Which means: Stale is the normal resting state for a healthy neighbor you have not talked to recently. It is not a fault. Almost everything in your cache will be Stale most of the time. The states that should get your attention are Unreachable, and Incomplete that never resolves.
Scenario 1: Is that host even on this segment?
This is the fastest thing the cmdlet does for you, and it separates two problems that look identical from the application layer.
You cannot reach 192.168.66.77. Ping times out. Is the host down, or is it not on your subnet at all, or is something filtering you?
Test-Connection 192.168.66.77 -Count 2 -ErrorAction SilentlyContinue | Out-Null
Get-NetNeighbor -IPAddress 192.168.66.77 -ErrorAction SilentlyContinueifIndex IPAddress LinkLayerAddress State PolicyStore
------- --------- ---------------- ----- -----------
14 192.168.66.77 00-00-00-00-00-00 Unreachable ActiveStoreUnreachable with the all-zeros link-layer address means your machine broadcast an ARP request onto the wire and nothing came back. Note the -ErrorAction SilentlyContinue on the lookup: if there is no entry for the address at all, Get-NetNeighbor -IPAddress does not return nothing quietly, it writes a red No MSFT_NetNeighbor objects found error. More on that in the gotchas.
Pull the property directly if you want to be certain what is in the entry rather than trusting a formatted column:
Get-NetNeighbor -IPAddress 192.168.66.77 -ErrorAction SilentlyContinue |
Select-Object IPAddress, State, LinkLayerAddress | Format-ListEither way the meaning is the same. That is a very specific finding. Layer 2 resolution failed. The host is powered off, or its NIC is down, or it is not on this VLAN, or a switch is isolating it. What it is not is a firewall problem, because ARP happens below anything a host firewall filters, and it is not a routing problem, because you never got far enough to route.
Now compare to the other outcome:
ifIndex IPAddress LinkLayerAddress State PolicyStore
------- --------- ---------------- ----- -----------
14 192.168.66.77 00-15-5D-3E-91-04 Reachable ActiveStore
The host answered ARP. It is alive, it is on your segment, its NIC works, and it responded inside the reachable-time window. Your ping still failing after that is a host firewall rule, an ICMP block, or something at the application layer. You have just cut the search space roughly in half with one command, and you did it without touching the remote machine.
I use this constantly. "Does it answer ARP" is a much better first question than "does it answer ping," because ARP is much harder to block by accident.
Scenario 2: Duplicate IP addresses, the classic
Two devices claiming the same IP is one of those failures that produces genuinely deranged symptoms. Intermittent connectivity. SSH sessions that die and reconnect to a different machine. File transfers that hash wrong. RDP that shows you somebody else's desktop.
The neighbor cache catches it because the MAC address for that IP will flap. Sample the cache repeatedly and watch:
$target = '192.168.66.50'
1..6 | ForEach-Object {
Test-Connection $target -Count 1 -ErrorAction SilentlyContinue | Out-Null
$n = Get-NetNeighbor -IPAddress $target -ErrorAction SilentlyContinue
[pscustomobject]@{
Sample = $_
MAC = $n.LinkLayerAddress
State = $n.State
}
Start-Sleep -Seconds 2
}
Sample MAC State
------ --- -----
1 00-15-5D-3E-91-04 Reachable
2 00-15-5D-3E-91-04 Reachable
3 B8-27-EB-6D-14-88 Reachable
4 00-15-5D-3E-91-04 Reachable
5 B8-27-EB-6D-14-88 Reachable
6 B8-27-EB-6D-14-88 Reachable
That is a duplicate IP, unambiguously. Two different pieces of hardware are both answering ARP for 192.168.66.50 and your machine is caching whichever answer arrived most recently. The MAC prefix B8-27-EB belongs to the Raspberry Pi Foundation, so I now also know that one of the two offenders is a Pi, which in my lab narrows it down considerably.
The other shape of this problem is one IP with entries on two different interfaces:
Get-NetNeighbor -AddressFamily IPv4 |
Where-Object { $_.State -ne 'Permanent' } |
Group-Object IPAddress |
Where-Object Count -gt 1 |
ForEach-Object { $_.Group } |
Sort-Object IPAddress, ifIndex |
Format-Table ifIndex, IPAddress, LinkLayerAddress, State
If the same address shows up under two interface indexes, you have overlapping subnets. Usually a VPN client handing you a range that collides with the local LAN, or a Hyper-V internal switch, or WSL's NAT network. The machine now has two plausible paths to the same address and it will pick one of them in a way that feels random to you.
Scenario 3: Verifying your gateway is who you think it is
After any firewall or gateway change, and any time I am suspicious about the segment, I check the gateway entry specifically. It is a two-command sanity check:
$gw = Get-NetRoute -DestinationPrefix '0.0.0.0/0' |
Sort-Object {
$_.RouteMetric +
(Get-NetIPInterface -InterfaceIndex $_.ifIndex -AddressFamily IPv4).InterfaceMetric
} |
Select-Object -First 1 -ExpandProperty NextHop
Get-NetNeighbor -IPAddress $gw | Format-Table ifIndex, IPAddress, LinkLayerAddress, State
The sort looks fussier than it needs to be and it is deliberate. Windows picks the default route by the sum of the route metric and the interface metric, not by the route metric alone. On a single-NIC desktop sorting on RouteMetric gets the right answer anyway. On the multi-homed machines from the next section, which are exactly the ones where you care, it does not.
ifIndex IPAddress LinkLayerAddress State
------- --------- ---------------- -----
14 192.168.66.1 74-AC-B9-4E-11-02 Reachable
That MAC should match what your router's admin interface says its LAN port MAC is, and it should not change. If it changes when you did not change hardware, something is answering ARP for your gateway address that should not be. That is either a device somebody plugged in with a static IP that collides with the gateway, a misconfigured second router, a failing HA pair flapping between members, or ARP spoofing.
I run this before and after any network change now, because it takes two seconds and it directly answers the question "am I still talking to the box I think I am talking to." When my post-migration troubleshooting started, this check is what told me the cache was the problem rather than the firewall rules, because the gateway MAC was correct and current while everything else in the table was months out of date.
Scenario 4: Ghost entries after decommissioning hardware
This is the one that started my evening. I pulled a Dell r730xd out of the lab a while back, and long after the physical machine was gone I still had cache entries referencing its NICs. They will eventually age out on their own, but "eventually" is longer than you want when you are actively debugging, and in the meantime they clutter every listing you look at.
Find entries that never resolved or that resolved to nothing:
Get-NetNeighbor -AddressFamily IPv4 |
Where-Object {
$_.State -ne 'Permanent' -and (
$_.State -in @('Unreachable','Incomplete') -or
$_.LinkLayerAddress -eq '00-00-00-00-00-00'
)
} |
Format-Table ifIndex, IPAddress, LinkLayerAddress, State
Note the Permanent filter on the front and the exact match on all zeros rather than a test for emptiness. Without the first you drag the multicast entries in, and without the second you flag loopback interfaces that are working perfectly.
Clear them out. Always dry run first:
Remove-NetNeighbor -State Unreachable -AddressFamily IPv4 -WhatIf
What if: Performing operation "Remove" on Target "NetNeighbor -IPAddress 192.168.66.77 -InterfaceIndex 14 -Store Active".
What if: Performing operation "Remove" on Target "NetNeighbor -IPAddress 192.168.66.91 -InterfaceIndex 14 -Store Active".Then do it for real:
Remove-NetNeighbor -State Unreachable -AddressFamily IPv4 -Confirm:$false
Two things to know here. First, Remove-NetNeighbor produces no output by default. Pass -PassThru if you want to see what it removed. Second, this requires an elevated session. Reading the cache with Get-NetNeighbor works fine as a standard user, but anything that writes to it (New-, Set-, Remove-) needs administrator. If you get an access denied from a script that reads fine interactively, that is why.
Removing an entry is safe. You are not breaking connectivity, you are just forcing the machine to re-resolve the address next time it needs it, which is exactly what you want when you suspect the cached answer is wrong. This is the PowerShell equivalent of arp -d, with the significant advantage that you can filter what you delete by state, address family, interface, or MAC instead of nuking the whole table.
If you do want the whole table gone on one interface:
Remove-NetNeighbor -InterfaceAlias 'Ethernet' -AddressFamily IPv4 -Confirm:$false
The multicast and broadcast entries get recreated immediately, so do not be surprised when they are back in the listing before you have finished reading the output.
Scenario 5: Which interface is this actually going out of?
Multi-homed machines lie to you. A laptop docked at a desk with Wi-Fi still on, a server with a management NIC and a data NIC, anything running Hyper-V or WSL. The symptom is "it works sometimes" and the cause is that traffic is leaving through an interface you were not thinking about.
Start by mapping index to name so the ifIndex column means something:
Get-NetAdapter | Sort-Object ifIndex | Format-Table ifIndex, Name, InterfaceDescription, Status, LinkSpeed
ifIndex Name InterfaceDescription Status LinkSpeed
------- ---- -------------------- ------ ---------
8 vEthernet (WSL) Hyper-V Virtual Ethernet Adapter Up 10 Gbps
11 Wi-Fi Intel(R) Wi-Fi 6E AX211 160MHz Up 1.2 Gbps
14 Ethernet Intel(R) Ethernet Connection I219-LM Up 1 Gbps
22 Tailscale Tailscale Tunnel Up 0 bps
Then join the two together so you can read the cache with adapter names attached:
$adapters = @{}
Get-NetAdapter | ForEach-Object { $adapters[[uint32]$_.ifIndex] = $_.Name }
Get-NetNeighbor -AddressFamily IPv4 |
Where-Object { $_.State -ne 'Permanent' } |
Select-Object @{n='Adapter';e={ $adapters[$_.ifIndex] }},
ifIndex, IPAddress, LinkLayerAddress, State |
Sort-Object Adapter, IPAddress |
Format-Table -AutoSize
Now Get-NetNeighbor -InterfaceAlias 'Ethernet' versus Get-NetNeighbor -InterfaceAlias 'Wi-Fi' becomes a real diagnostic. If the host you care about only has an entry on the interface you did not expect, you have found your problem.
There is a companion cmdlet in the same module that answers the routing half of this directly:
Find-NetRoute -RemoteIPAddress 192.168.66.5
That tells you which local address and interface the stack would actually pick for a given destination. Pair it with the neighbor cache and you can trace the decision end to end: which interface, which next hop, which MAC.
One more note on this. By default Get-NetNeighbor shows you the default network compartment only. Network compartments are the isolation mechanism Windows Containers and Application Guard use, so if you are running containers, add -IncludeAllCompartments to see inside them:
Get-NetNeighbor -IncludeAllCompartments -AddressFamily IPv4
Worth being precise here, because I had this wrong for a while: WSL2 is not a compartment case. It runs in a Hyper-V utility VM with its own Linux network stack, and the host-side vEthernet (WSL) adapter sits in the default compartment like any other. You can see it in the plain Get-NetAdapter output above at index 8. -IncludeAllCompartments will not show you anything extra about WSL.
Scenario 6: The IPv6 side, which is where the surprises live
IPv6 is on by default and most people never look at it, right up until it becomes the reason something behaves strangely. Windows follows the RFC 6724 address selection rules, which rank IPv6 above IPv4 when the application is handed both.
That last clause is the important qualifier. The application has to actually receive an IPv6 address to prefer, and link-local addresses are essentially never in DNS, so in practice this bites you through mDNS or LLMNR name resolution rather than through ordinary DNS lookups. It is also reversible: the DisabledComponents registry value set to 0x20 flips the preference back to IPv4, and plenty of managed fleets have exactly that in place. So do not assume, check.
Get-NetNeighbor -AddressFamily IPv6 |
Where-Object { $_.State -ne 'Permanent' } |
Format-Table ifIndex, IPAddress, LinkLayerAddress, State
ifIndex IPAddress LinkLayerAddress State
------- --------- ---------------- -----
14 fe80::1 74-AC-B9-4E-11-02 Reachable
14 fe80::ba27:ebff:fe6d:1488 B8-27-EB-6D-14-88 Stale
14 fe80::be24:11ff:fe9a:3c07 BC-24-11-9A-3C-07 Reachable
Look at the relationship between those link-local addresses and the MACs. fe80::ba27:ebff:fe6d:1488 maps to MAC B8-27-EB-6D-14-88. That is EUI-64: take the MAC, split it in half, insert FF-FE in the middle, flip the seventh bit of the first byte (B8 becomes BA). Once you can read that, a link-local address tells you the hardware it belongs to at a glance, no lookup required.
This matters for troubleshooting because it lets you correlate. If a host appears in your IPv6 cache with one MAC and in your IPv4 cache with a different MAC for what you believe is the same device, they are not the same device.
The practical rule I follow: when you are debugging a connectivity problem and the IPv4 story looks clean, check the v6 cache before you conclude anything. More than once the answer has been that traffic was going out over IPv6 to a neighbor I had not accounted for, while I stared at a perfectly healthy IPv4 table.
Scenario 7: Identifying what is actually on your network
A neighbor cache full of MAC addresses is a hardware inventory if you know how to read the first three octets. That prefix is the OUI, the Organizationally Unique Identifier, assigned by the IEEE to the manufacturer.
The reliable trick for building a full picture is to populate the cache deliberately before you read it. ARP resolution only happens when there is a reason for it, so an idle machine has a sparse cache. Sweep the subnet, then read:
# Populate the cache by touching every host on the /24
1..254 | ForEach-Object -ThrottleLimit 64 -Parallel {
Test-Connection "192.168.66.$_" -Count 1 -TimeoutSeconds 1 -Quiet | Out-Null
}
Start-Sleep -Seconds 2
Get-NetNeighbor -AddressFamily IPv4 -InterfaceAlias 'Ethernet' |
Where-Object { $_.State -notin @('Permanent','Unreachable') } |
Select-Object IPAddress,
LinkLayerAddress,
@{n='OUI';e={ ($_.LinkLayerAddress -split '-')[0..2] -join '-' }},
State |
Sort-Object { [version]$_.IPAddress } |
Format-Table -AutoSize
Two things about that snippet if you are on Windows PowerShell 5.1. ForEach-Object -Parallel is PowerShell 7 only, and so is Test-Connection -TimeoutSeconds, which arrived in PowerShell 6. Swapping just the loop construct is not enough, you will get a parameter binding error on the timeout. On 5.1 use a plain foreach with Test-Connection -Count 1 -Quiet and no timeout parameter, and expect it to take considerably longer because it runs serially and waits out the default timeout on every dead address.
The [version] cast in the sort is a cheap trick to get numeric ordering out of dotted-quad strings, so .9 comes before .42 instead of after it. It only works for IPv4, and it will throw if you feed it an IPv6 address, so keep it scoped to a v4 listing.
The result is every device that answered ARP on that segment, with its MAC and vendor prefix. This is genuinely useful for the question "what is that thing at .118 and where did it come from," and it works even for devices that ignore ping, because ARP has to be answered for the segment to function at all. A device that drops ICMP will still show up here.
I want to be careful about vendor claims, because OUI assignments change hands and reassignments happen. B8-27-EB is Raspberry Pi Foundation, which is well documented and stable. For anything else, look the prefix up against a current IEEE OUI listing rather than trusting a hardcoded table in a script. The point of the OUI column above is to give you something to paste into a lookup, not to be authoritative on its own.
Scenario 8: Checking a remote machine
Everything above works against another host without leaving your session, because Get-NetNeighbor is a CIM cmdlet. It takes -CimSession:
$s = New-CimSession -ComputerName 'lab-host-01'
Get-NetNeighbor -CimSession $s -AddressFamily IPv4 |
Where-Object { $_.State -ne 'Permanent' } |
Format-Table PSComputerName, ifIndex, IPAddress, LinkLayerAddress, State
Remove-CimSession $s
This is very good for the class of problem where two machines disagree about the network. Pull the cache from both, compare the entry for the same IP, and see whether they resolved to the same MAC. If host A thinks 192.168.66.50 is one MAC and host B thinks it is another, you have your duplicate, and you found it without walking to either machine.
You can fan out across several hosts at once:
$sessions = New-CimSession -ComputerName 'lab-host-01','lab-host-02','lab-host-03'
Get-NetNeighbor -CimSession $sessions -IPAddress 192.168.66.50 |
Select-Object PSComputerName, IPAddress, LinkLayerAddress, State |
Format-Table -AutoSize
Remove-CimSession $sessions
-AsJob and -ThrottleLimit are there too if you are hitting enough machines that you want it in the background.
Scenario 9: Pinning a static entry, and why you probably should not
New-NetNeighbor creates a permanent entry. Only Permanent is a valid state to create, because every other state is managed by the stack.
New-NetNeighbor -InterfaceIndex 14 `
-IPAddress '192.168.66.5' `
-LinkLayerAddress 'BC-24-11-9A-3C-07' `
-PolicyStore ActiveStore
And to change one that already exists:
Get-NetNeighbor -InterfaceIndex 14 -IPAddress '192.168.66.5' |
Set-NetNeighbor -LinkLayerAddress 'BC-24-11-9A-3C-07'
Note that Set-NetNeighbor can only modify entries that are already in the Permanent state. You cannot reach into a dynamically learned Stale entry and rewrite its MAC. If you need to correct a dynamic entry, remove it and let it re-resolve, or create a permanent one.
The -PolicyStore choice matters more than it looks. If you omit it, the entry goes into both ActiveStore and PersistentStore, which means it survives reboots. That is how you create a static ARP entry that outlives you and confuses whoever debugs the machine in two years. When I am pinning something temporarily to test a theory, I specify -PolicyStore ActiveStore explicitly so it dies on reboot.
Use this for diagnosis, not configuration. Pinning an IP to a known-good MAC is a legitimate way to test "is this a resolution problem or something further up the stack." Leaving it in place as a fix hides the real problem and plants a landmine that detonates the next time that hardware is replaced. Static ARP entries are a debugging tool people keep mistaking for a solution.
A neighbor cache audit script
Here is what I actually keep around. It filters the noise, resolves interface names, flags the things worth looking at, and detects duplicate MAC assignments in one pass.
function Get-NeighborReport {
[CmdletBinding()]
param(
[string]$InterfaceAlias,
[ValidateSet('IPv4','IPv6')]
[string]$AddressFamily = 'IPv4'
)
$adapters = @{}
Get-NetAdapter | ForEach-Object { $adapters[[uint32]$_.ifIndex] = $_.Name }
$params = @{ AddressFamily = $AddressFamily }
if ($InterfaceAlias) { $params['InterfaceAlias'] = $InterfaceAlias }
$entries = Get-NetNeighbor @params |
Where-Object { $_.State -ne 'Permanent' }
# MACs claimed by more than one IP on the same interface.
# '00-00-00-00-00-00' is a non-empty string, so it is truthy and has to be
# excluded explicitly. Leave it in and every failed resolution on the box
# groups with every other one and comes back flagged SHARED-MAC.
$dupeMacs = $entries |
Where-Object { $_.LinkLayerAddress -and
$_.LinkLayerAddress -ne '00-00-00-00-00-00' } |
Group-Object ifIndex, LinkLayerAddress |
Where-Object Count -gt 1 |
ForEach-Object { ($_.Name -split ', ')[1] }
# IPs claimed on more than one interface
$dupeIps = $entries |
Group-Object IPAddress |
Where-Object Count -gt 1 |
ForEach-Object { $_.Name }
foreach ($e in $entries) {
$flags = [System.Collections.Generic.List[string]]::new()
if ($e.State -in @('Unreachable','Incomplete')) { $flags.Add('NO-RESOLVE') }
if (-not $e.LinkLayerAddress -or
$e.LinkLayerAddress -eq '00-00-00-00-00-00') { $flags.Add('NO-MAC') }
if ($e.LinkLayerAddress -in $dupeMacs) { $flags.Add('SHARED-MAC') }
if ($e.IPAddress -in $dupeIps) { $flags.Add('MULTI-IF') }
[pscustomobject]@{
Adapter = $adapters[$e.ifIndex]
ifIndex = $e.ifIndex
IP = $e.IPAddress
MAC = $e.LinkLayerAddress
OUI = if ($e.LinkLayerAddress -and
$e.LinkLayerAddress -ne '00-00-00-00-00-00') {
($e.LinkLayerAddress -split '-')[0..2] -join '-'
} else { $null }
State = $e.State
Flags = ($flags -join ',')
}
}
}Running it:
Get-NeighborReport -InterfaceAlias 'Ethernet' | Format-Table -AutoSize
Adapter ifIndex IP MAC OUI State Flags
------- ------- -- --- --- ----- -----
Ethernet 14 192.168.66.1 74-AC-B9-4E-11-02 74-AC-B9 Reachable
Ethernet 14 192.168.66.5 BC-24-11-9A-3C-07 BC-24-11 Reachable
Ethernet 14 192.168.66.42 B8-27-EB-6D-14-88 B8-27-EB Stale SHARED-MAC
Ethernet 14 192.168.66.50 B8-27-EB-6D-14-88 B8-27-EB Reachable SHARED-MAC
Ethernet 14 192.168.66.77 00-00-00-00-00-00 Unreachable NO-RESOLVE,NO-MACThat SHARED-MAC flag on .42 and .50 is worth explaining, because it has two very different causes. One MAC answering for multiple IPs is completely normal for a router doing proxy ARP, or a host with several addresses bound to one NIC, or a load balancer. It is also exactly what ARP spoofing looks like. The flag is not an accusation, it is a prompt to go find out which of those it is.
To grab a snapshot for comparison later, or to hand to somebody else:
Get-NeighborReport | Export-Csv -Path "$env:USERPROFILE\neighbors-before.csv" -NoTypeInformation
# make the change
Get-NeighborReport | Export-Csv -Path "$env:USERPROFILE\neighbors-after.csv" -NoTypeInformation
Compare-Object (Import-Csv "$env:USERPROFILE\neighbors-before.csv") `
(Import-Csv "$env:USERPROFILE\neighbors-after.csv") `
-Property IP, MAC, State
Before-and-after diffs around a network change are the single highest-value thing in this whole post. If I had taken one before the firewall migration, I would have spotted the ghost entries in about four seconds instead of forty minutes.
Gotchas
A few things that cost me time so they do not have to cost you any.
The cache is a cache. It reflects what your machine learned, not ground truth. An entry can be confidently wrong. When the cache and reality disagree, the cache does not correct itself until the entry ages out or something forces re-resolution. Removing an entry and letting it re-resolve is a legitimate diagnostic step, not a workaround.
No entry is not an empty result, it is an error. This one surprised me. If you run Get-NetNeighbor -IPAddress 10.0.0.5 and your machine has no entry for that address, you do not get nothing back, you get a red non-terminating error:
Get-NetNeighbor: No MSFT_NetNeighbor objects found with property 'IPAddress' equal to '10.0.0.5'.
Verify the value of the property and retry.Nothing is wrong. Either the address is off-link, or nothing has tried to reach it. Send some traffic and look again. But it means every filtered lookup you put in a script needs -ErrorAction SilentlyContinue, and if you are testing existence, test the count rather than trusting $?. The same applies to -State, -LinkLayerAddress, and the rest: pass -State Unreachable, Incomplete on a machine with no Incomplete entries and you get the Unreachable ones and an error about Incomplete.
Filter out Permanent before you interpret anything. The multicast and broadcast entries will always be there, they will always be Permanent, and they will always outnumber the real devices on a quiet machine.
Stale is fine. Repeating this because it is the single most common misread. If you want to know whether something is reachable right now, send traffic and re-check, do not read the state of an idle entry and draw conclusions.
Reads are unprivileged, writes are not. Get-NetNeighbor works as a standard user. New-, Set-, and Remove-NetNeighbor need an elevated session. A script that works when you test it in your admin console and fails under a scheduled task running as a service account will fail exactly here.
This is Windows only. The NetTCPIP module is part of Windows. It is not available in PowerShell on Linux or macOS. On Linux you want ip neigh, and on macOS you want arp -an for IPv4 and ndp -an for IPv6. Microsoft's module compatibility documentation lists NetTCPIP as natively compatible with PowerShell 7 on Windows Server 1809 and later and Windows 10 1809 and later, so you do not need the Windows PowerShell compatibility layer for it. It works the same in PowerShell 7.6 as it does in Windows PowerShell 5.1.
Watch the interface index. More diagnostic time gets lost to reading the right data from the wrong interface than to anything else on this list. Join against Get-NetAdapter and put the adapter name in your output. It costs three lines and saves you from a whole category of wrong conclusion.
MAC formatting is not consistent across tools. Get-NetNeighbor returns dash separated uppercase (B8-27-EB-6D-14-88). Your switch probably shows colon separated lowercase (b8:27:eb:6d:14:88). Normalize before comparing, or you will "prove" two identical MACs are different.
Quick reference
| Task | Command |
|---|---|
| Everything | Get-NetNeighbor |
| Real devices only, IPv4 | Get-NetNeighbor -AddressFamily IPv4 | Where-Object State -ne 'Permanent' |
| One address | Get-NetNeighbor -IPAddress 192.168.66.5 -ErrorAction SilentlyContinue |
| One interface | Get-NetNeighbor -InterfaceAlias 'Ethernet' |
| By MAC | Get-NetNeighbor -LinkLayerAddress 'B8-27-EB-6D-14-88' |
| Failed resolutions | Get-NetNeighbor -State Unreachable, Incomplete -ErrorAction SilentlyContinue |
| Every property | Get-NetNeighbor -IPAddress 192.168.66.5 | Format-List * |
| Include container compartments | Get-NetNeighbor -IncludeAllCompartments |
| A remote machine | Get-NetNeighbor -CimSession (New-CimSession -ComputerName host01) |
| Preview a cleanup | Remove-NetNeighbor -State Unreachable -WhatIf |
| Clear failed entries | Remove-NetNeighbor -State Unreachable -Confirm:$false |
| Pin a temporary static entry | New-NetNeighbor -InterfaceIndex 14 -IPAddress 192.168.66.5 -LinkLayerAddress BC-24-11-9A-3C-07 -PolicyStore ActiveStore |
| Reachability timers | Get-NetIPInterface -InterfaceIndex 14 -AddressFamily IPv4 | Select-Object ReachableTime, BaseReachableTime |
| Which interface for a destination | Find-NetRoute -RemoteIPAddress 192.168.66.5 |
What I actually took away from this
My problem that morning was not a firewall rule. I changed the network and the machines on it kept operating from what they had learned before the change. The neighbor cache is the most local, most immediate version of that gap between what a machine believes and what is true, and it is one command away.
The habit that stuck: snapshot the cache before a network change, snapshot it after, diff the two. And when something is broken between the cable and the application, ask "does it answer ARP" before you ask anything else. That question takes under a second, it does not require touching the other machine, and it eliminates an enormous amount of the search space.
Get-NetNeighbor is not a glamorous cmdlet. It reads a table. But it reads the specific table sitting underneath every other assumption you are making about your local network, and I have stopped being surprised by how often the answer is right there in it.
References
- Get-NetNeighbor (NetTCPIP)
- Remove-NetNeighbor (NetTCPIP)
- New-NetNeighbor (NetTCPIP)
- Set-NetNeighbor (NetTCPIP)
- Get-NetIPInterface (NetTCPIP)
- NetTCPIP module reference
- Windows PowerShell module compatibility with PowerShell 7