netstat vs ss: Why It Was Replaced and How to Switch

The full command map, wall clock timings on a host holding ten thousand sockets, and the surprise result when you add process lookup.

Terminal comparing ss and netstat wall clock times on a host with 13,330 TCP sockets

Every Linux networking guide written since about 2011 opens with some version of "netstat is deprecated, use ss." Very few of them say what that actually costs you, what the replacement commands are one for one, or whether it matters on a machine with a normal number of connections. This article does all three, with a benchmark on a host holding ten thousand open sockets, and it finishes with the two tools that neither netstat nor ss replaces: lsof and fuser.

All output is real, captured on a Debian 13 host under live traffic through a Cisco Modeling Labs topology. It is part of the Linux networking commands cluster.

What actually happened to net-tools

netstat ships in the net-tools package, alongside ifconfig, route and arp. That package was effectively unmaintained through the 2000s, and in 2009 Debian marked it deprecated in favor of iproute2. Fedora dropped it from the default install in 2011. Both packages are still installable and both are on this host:

j@llmbits:~$ dpkg -s net-tools | grep -E '^(Package|Version|Description)'
Package: net-tools
Version: 2.10-1.3
Description: NET-3 networking toolkit

j@llmbits:~$ dpkg -s iproute2 | grep -E '^(Package|Version|Description)'
Package: iproute2
Version: 6.15.0-1
Description: networking and traffic control tools

Look at the version numbers. net-tools 2.10 was released in 2021 after a long gap; iproute2 tracks the kernel and is at 6.15. That gap is the whole argument. net-tools was written against an interface the kernel has moved past, and it cannot express things the kernel has been able to do for fifteen years.

The technical difference is how they get their data. netstat opens text files under /proc/net and parses them. ss talks to the kernel over a netlink socket using the sock_diag protocol, which is a binary interface designed for exactly this, supports filtering on the kernel side, and can return per socket TCP state that has no representation in the /proc text format at all.

The command map

Nothing you know is wasted. Every common netstat invocation has a direct replacement:

netstat -tulpn ss -tulpn Listening sockets with processes. Same flags.
netstat -tan ss -tan All TCP sockets, numeric.
netstat -rn ip route show Routing table. Different tool, not ss.
netstat -i ip -s link Interface counters.
netstat -g ip maddr Multicast group membership.
netstat -s nstat Protocol statistics. nstat shows deltas.
netstat -c watch -n1 ss ... Continuous mode. ss has no -c.

Side by side

Listeners, the same moment, both tools:

j@llmbits:~$ sudo netstat -tulpn | head -12
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      1272/sshd: /usr/sbi
tcp        0      0 127.0.0.1:631           0.0.0.0:*               LISTEN      8139/cupsd
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN      10443/python3
tcp6       0      0 :::22                   :::*                    LISTEN      1272/sshd: /usr/sbi
tcp6       0      0 ::1:631                 :::*                    LISTEN      8139/cupsd
udp        0      0 0.0.0.0:57029           0.0.0.0:*                           896/avahi-daemon: r
udp        0      0 0.0.0.0:5353            0.0.0.0:*                           896/avahi-daemon: r
j@llmbits:~$ sudo ss -tulpn | head -12
Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
udp   UNCONN 0      0            0.0.0.0:57029      0.0.0.0:*    users:(("avahi-daemon",pid=896,fd=14))
udp   UNCONN 0      0            0.0.0.0:5353       0.0.0.0:*    users:(("avahi-daemon",pid=896,fd=12))
tcp   LISTEN 0      128          0.0.0.0:22         0.0.0.0:*    users:(("sshd",pid=1272,fd=6))
tcp   LISTEN 0      4096       127.0.0.1:631        0.0.0.0:*    users:(("cupsd",pid=8139,fd=7))
tcp   LISTEN 0      5            0.0.0.0:8080       0.0.0.0:*    users:(("python3",pid=10443,fd=3))
tcp   LISTEN 0      128             [::]:22            [::]:*    users:(("sshd",pid=1272,fd=7))
tcp   LISTEN 0      4096           [::1]:631           [::]:*    users:(("cupsd",pid=8139,fd=6))

The same facts, and three differences worth noticing.

netstat truncates the process name to a fixed width, so sshd reads as sshd: /usr/sbi. ss gives the clean process name plus the file descriptor number, which is what you want when correlating with strace or lsof.

netstat shows Send-Q as 0 on every listener. ss shows 128, 4096 and 5, which are the real accept backlogs. netstat is not reading a different kernel, it simply has nowhere in its output format to put the number.

netstat writes tcp6 and :::22; ss writes tcp with [::]:22. The ss notation is the standard bracketed form and is much easier to read on a host with real IPv6 addresses.

Established connections through the lab, both tools:

j@llmbits:~$ netstat -tan | grep 10.77.3.10 | head -6
tcp        0 230508 10.77.0.100:44766       10.77.3.10:5201         ESTABLISHED
tcp        0 257468 10.77.0.100:44778       10.77.3.10:5201         ESTABLISHED
tcp        0 261512 10.77.0.100:44828       10.77.3.10:5201         ESTABLISHED
tcp        0 217028 10.77.0.100:44802       10.77.3.10:5201         ESTABLISHED
tcp        0      0 10.77.0.100:58176       10.77.3.10:9000         ESTABLISHED
tcp        0 119972 10.77.0.100:44786       10.77.3.10:5201         ESTABLISHED

j@llmbits:~$ ss -tan dst 10.77.3.10 | head -6
State     Recv-Q Send-Q Local Address:Port  Peer Address:Port
ESTAB     0      194112   10.77.0.100:44766   10.77.3.10:5201
ESTAB     0      210288   10.77.0.100:44778   10.77.3.10:5201
ESTAB     0      234552   10.77.0.100:44828   10.77.3.10:5201
ESTAB     0      314084   10.77.0.100:44802   10.77.3.10:5201
ESTAB     0      0        10.77.0.100:58176   10.77.3.10:9000

Here the grep in the first command is doing something the second does properly. ss filtered on dst 10.77.3.10 in the kernel, so no rows were ever generated for connections that did not match. And a grep for an address string will happily match 10.77.3.100 and 110.77.3.10 as well, which ss dst 10.77.3.0/24 will not.

Routing and interfaces

netstat -rn is where the deprecation actually bites, because the output format cannot represent a modern routing table:

j@llmbits:~$ netstat -rn
Kernel IP routing table
Destination     Gateway         Genmask         Flags   MSS Window  irtt Iface
0.0.0.0         192.168.88.1    0.0.0.0         UG        0 0          0 ens192
10.77.0.0       0.0.0.0         255.255.255.0   U         0 0          0 ens224
10.77.0.0       10.77.0.1       255.255.0.0     UG        0 0          0 ens224
192.168.88.0    0.0.0.0         255.255.255.0   U         0 0          0 ens192

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 101

The netstat version loses the protocol that installed each route (proto dhcp against proto kernel matters when you are working out who put a route there), loses the preferred source address, and loses the metric. It also shows two lines both starting 10.77.0.0 with no visual indication that one is a /24 and the other a /16. It shows nothing at all about policy routing tables, and netstat has no equivalent of ip route get, which is the single most useful routing command on Linux. That is covered in managing the Linux routing table.

Interface counters are the one place netstat genuinely reads better:

j@llmbits:~$ netstat -i
Kernel Interface table
Iface             MTU    RX-OK RX-ERR RX-DRP RX-OVR    TX-OK TX-ERR TX-DRP TX-OVR Flg
ens192           1500   654499      0 128152 0        109651      0      0      0 BMRU
ens224           1500    48016      0      0 0         49448      0      0      0 BMRU
lo              65536     3151      0      0 0          3151      0      0      0 LRU

One line per interface, errors and drops in columns you can scan. ip -s link gives the same numbers plus more, but spread over four lines per interface. If you like the netstat -i layout, ip -s -br link is the closest brief equivalent, and ifstat is better than either for watching rates rather than totals. The full net-tools to iproute2 mapping for interfaces is in ifconfig, route, netstat: legacy net-tools to iproute2.

Protocol statistics: netstat -s and nstat

j@llmbits:~$ netstat -s | head -18
Ip:
    Forwarding: 2
    313555 total packets received
    14 with invalid addresses
    0 forwarded
    0 incoming packets discarded
    313478 incoming packets delivered
    151917 requests sent out
    132 dropped because of missing route
    OutTransmits: 153597
Icmp:
    2180 ICMP messages received
    20 input ICMP message failed
    ICMP input histogram:
        destination unreachable: 60
        timeout in transit: 1632
        echo requests: 38
        echo replies: 450

That timeout in transit: 1632 is every ICMP time exceeded this host received while the traceroute and mtr work for this cluster was going on, which is a nice illustration of what these counters are: cumulative since boot, and therefore almost useless for answering "is it happening now."

That is what nstat fixes. It remembers the last values it printed and shows you the change:

j@llmbits:~$ nstat -az | grep -E 'TcpRetransSegs|TcpInSegs|TcpOutSegs|TcpExtTCPLostRetransmit'
TcpInSegs                       152958             0.0
TcpOutSegs                      185504             0.0
TcpRetransSegs                  2006               0.0
TcpExtTCPLostRetransmit         54                 0.0

2006 retransmitted segments out of 185504 sent, or about 1.1 percent, on a host that spent the last hour pushing iperf3 through a deliberately impaired link. Run nstat without -a twice and the second run shows only what changed in between, which turns a boot time counter into a live rate. Nothing in net-tools does that.

The benchmark

Everyone repeats that ss is faster. Here is the measurement, on a host holding ten thousand established TCP sockets plus a few thousand in time-wait:

j@llmbits:~$ ss -s | head -2
Total: 10488
TCP:   13330 (estab 10003, closed 3322, orphaned 0, timewait 3322)

j@llmbits:~$ ss -tan | wc -l
13331
j@llmbits:~$ for i in 1 2 3; do time ss -tan > /dev/null; done
real 0.150  user 0.080  sys 0.069
real 0.148  user 0.080  sys 0.069
real 0.149  user 0.074  sys 0.075

j@llmbits:~$ for i in 1 2 3; do time netstat -tan > /dev/null; done
real 0.377  user 0.124  sys 0.253
real 0.375  user 0.128  sys 0.248
real 0.375  user 0.135  sys 0.239

Two and a half times faster, and consistent across runs. Look at the sys column for the reason: 0.07 seconds of kernel time for ss against 0.25 for netstat. That is the cost of formatting thirteen thousand socket records as text in /proc and parsing them back out again.

Now the part the internet usually omits. Add process lookup:

j@llmbits:~$ for i in 1 2 3; do time sudo ss -tanp > /dev/null; done
real 1.079  user 0.341  sys 0.692
real 0.716  user 0.181  sys 0.484
real 0.742  user 0.225  sys 0.490

j@llmbits:~$ for i in 1 2 3; do time sudo netstat -tanp > /dev/null; done
real 0.436  user 0.087  sys 0.317
real 0.462  user 0.099  sys 0.341
real 0.753  user 0.158  sys 0.569

ss -p is slower than netstat -p here, roughly 0.7 to 1.1 seconds against 0.4 to 0.8. Both tools have to walk every process's file descriptors in /proc to map socket inodes to processes, because there is no fast kernel interface for that mapping, and neither the netlink path nor anything else helps. The socket enumeration is where ss wins, and adding -p swamps that win with work both tools do the same slow way.

The practical lesson: drop -p when you are enumerating a lot of sockets and do not need process names. On a busy load balancer, ss -tan state time-wait | wc -l is cheap and ss -tanp is not.

And the honest scale caveat: at 13,000 sockets the difference is 0.15 against 0.38 seconds. On a laptop with 40 sockets neither is measurable. If you are keeping netstat muscle memory on a workstation, nothing bad is happening to you. The argument for switching is about capability and about the machines where it does matter.

What netstat simply cannot do

Kernel side filtering
ss state established '( dport = :443 )' never generates the rows you do not want. netstat generates everything and you grep.
TCP internals
ss -i gives rtt, cwnd, retransmitted bytes and delivery rate per socket. netstat has no equivalent at all.
Socket memory
ss -m shows buffer sizes and socket level drops. Nothing in net-tools reports these.
Destroying sockets
ss -K kills a matching connection without killing the process. There is no netstat equivalent.
Accept backlogs
ss reports the listen backlog and its current depth. netstat prints zeros.
Modern routing
No route protocols, no metrics, no source hints, no policy tables, and no route get.

What netstat still has going for it: it is on every ancient box you will ever have to log into, its -i table is more readable than ip -s link, and netstat -s gives a friendlier protocol summary than nstat -az if you only want to read it once. Knowing it is not wasted knowledge, it is a portability skill.

lsof: sockets as files

Neither tool replaces lsof, because lsof answers a different question. ss starts from sockets and finds processes. lsof starts from open files, and on Unix a socket is a file:

j@llmbits:~$ sudo lsof -nP -iTCP -sTCP:LISTEN
COMMAND   PID USER FD   TYPE DEVICE SIZE/OFF NODE NAME
sshd     1272 root 6u  IPv4   7039      0t0  TCP *:22 (LISTEN)
sshd     1272 root 7u  IPv6   7041      0t0  TCP *:22 (LISTEN)
cupsd    8139 root 6u  IPv6 195177      0t0  TCP [::1]:631 (LISTEN)
cupsd    8139 root 7u  IPv4 195178      0t0  TCP 127.0.0.1:631 (LISTEN)
python3 10443    j 3u  IPv4 390016      0t0  TCP *:8080 (LISTEN)

-n and -P together mean no name resolution and no port name translation, and you want both every time. -iTCP -sTCP:LISTEN filters to TCP in the LISTEN state.

The @host syntax is where lsof is genuinely nicer than ss, because it reads like English:

j@llmbits:~$ sudo lsof -nP -i @10.77.3.10
COMMAND   PID USER FD   TYPE DEVICE SIZE/OFF NODE NAME
nc      10378    j  3u  IPv4 394047      0t0  TCP 10.77.0.100:58176->10.77.3.10:9000 (ESTABLISHED)
nc      10445    j  3u  IPv4 392065      0t0  TCP 10.77.0.100:58968->10.77.3.10:9000 (ESTABLISHED)
iperf3  10446    j  4u  IPv4 399096      0t0  TCP 10.77.0.100:44762->10.77.3.10:5201 (ESTABLISHED)
iperf3  10446    j  5u  IPv4 399097      0t0  TCP 10.77.0.100:44766->10.77.3.10:5201 (ESTABLISHED)
iperf3  10446    j  7u  IPv4 399098      0t0  TCP 10.77.0.100:44778->10.77.3.10:5201 (ESTABLISHED)

And "who has port 8080 open" is a one liner:

j@llmbits:~$ sudo lsof -nP -i :8080
COMMAND   PID USER FD   TYPE DEVICE SIZE/OFF NODE NAME
python3 10443    j 3u  IPv4 390016      0t0  TCP *:8080 (LISTEN)

The reason to keep lsof in the toolbox is that it crosses the boundary. When a process holds a socket and a log file and a deleted file that is still consuming disk, lsof -p <pid> shows all of it in one place. ss will never tell you about the deleted file.

fuser: the fastest possible answer

fuser answers exactly one question, which is "which process has this," and it is the quickest tool for it:

j@llmbits:~$ fuser -n tcp 8080
8080/tcp:            10443

j@llmbits:~$ sudo fuser -v -n tcp 8080
                     USER        PID ACCESS COMMAND
8080/tcp:            j         10443 F.... python3

And it can act on the answer, which is why it exists:

j@llmbits:~$ sudo fuser -k -n tcp 8080
8080/tcp:            10443

j@llmbits:~$ sudo ss -tlnp '( sport = :8080 )'
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess

fuser -k sent SIGKILL to the process holding port 8080, and the follow up ss confirms nothing is listening there any more. This is the correct fix for the "address already in use" message when a previous run of a service did not exit cleanly, and it beats ps aux | grep followed by copying a pid by hand.

Two warnings. fuser -k defaults to SIGKILL, not SIGTERM, so the process gets no chance to shut down cleanly. Use -k -TERM if that matters. And run it without -k first, every single time, because killing the wrong pid on a production box is a bad afternoon. On Debian and derivatives, fuser comes from the psmisc package and is not always installed by default.

Which tool for which question

What is listening on this box?
ss -tulpn
Who is holding port 8080 so I can restart the service?
fuser -v -n tcp 8080
Everything this one process has open, sockets and files?
lsof -nP -p <pid>
Is this connection actually moving data, and how well?
ss -tin dst <peer>
Are we retransmitting right now?
nstat (run it twice)
I am on a 2008 box with no iproute2.
netstat -tulpn, and no apology needed

FAQ

Is netstat actually being removed?

Not from the archives. net-tools is still packaged and installable everywhere. What changed is that it stopped being installed by default on most modern distributions, so a script that shells out to netstat will fail on a minimal container image. That is the practical reason to port automation to ss, more than any performance argument.

Are the output formats stable enough to parse?

Neither is a contract. If something downstream needs socket data, use ss -H to drop the header and pick fields carefully, or read /proc/net/tcp directly, or use the sock_diag netlink interface from a real program. Do not build a monitoring system on a grep of either tool's human output.

Why is netstat -p showing blanks for some processes?

Same reason as ss -p. Both need root to inspect other users' file descriptors. Neither is broken.

You said ss is faster, then showed it slower. Which is it?

Enumerating sockets, ss is about 2.5 times faster at thirteen thousand sockets, and the gap widens as the count grows. Mapping sockets to processes, both tools do the same expensive /proc walk and ss showed no advantage in this test. The speed claim is about socket enumeration, and it is worth being precise about that rather than repeating the headline.

Does any of this apply to netstat on Windows?

No. Windows netstat is a completely separate implementation with its own flags, it is not deprecated, and it is the correct tool there. Only the Linux net-tools package is what this article is about.

Key takeaways

  • net-tools was deprecated because it parses /proc text, while ss uses the sock_diag netlink interface and can filter in the kernel.
  • Every common netstat invocation maps directly: -tulpn to ss -tulpn, -rn to ip route, -i to ip -s link, -s to nstat.
  • At 13,000 sockets, ss -tan took 0.15 s against netstat -tan at 0.38 s. With -p the advantage disappears, because both walk /proc the same slow way.
  • Skip -p when you are counting sockets rather than identifying owners.
  • netstat prints zeros for listen backlogs and has no way to show TCP internals, socket memory, or policy routing. Those gaps are why it was replaced.
  • nstat shows deltas since its last run, which turns useless boot time counters into a live retransmit rate.
  • lsof -nP -i @host and lsof -nP -i :port are the friendliest syntax for the "who is talking to X" question, and lsof crosses from sockets into files.
  • fuser -v -n tcp <port> then fuser -k -n tcp <port> is the fastest fix for a port held by a stale process. Run it without -k first, and remember it defaults to SIGKILL.

For everything ss can do once you commit to it, including the filter language and the per socket TCP internals, read ss, the modern socket statistics tool. The interface and routing halves of the same migration are covered in ifconfig, route, netstat to iproute2 and ip route. Everything in this series is indexed on the Linux networking commands guide.

Read next