How to Write a Simple TCP Port Scanner in Go in Real Scripts
Write a simple concurrent TCP port scanner in Go with net.DialTimeout, a bounded worker pool, sorted results and notes on filtered versus closed ports.

Checking whether a service port is reachable is a daily task, and nc -z host 22 does it for one port. Checking a range, quickly and with readable output, is a good first Go network program: short, concurrent and useful. Scan only systems you operate or have explicit permission to test, since unauthorized scanning may break the law and your hosting terms. This builds on the concurrency basics in worker pools, and the same ideas drive the SSH client. Targets Go 1.24.
The scanner
package main
import (
"flag"
"fmt"
"net"
"sort"
"sync"
"time"
)
func main() {
host := flag.String("host", "127.0.0.1", "host to scan")
from := flag.Int("from", 1, "first port")
to := flag.Int("to", 1024, "last port")
workers := flag.Int("workers", 100, "concurrent connections")
timeout := flag.Duration("timeout", 800*time.Millisecond, "per-port timeout")
flag.Parse()
ports := make(chan int)
var mu sync.Mutex
var open []int
var wg sync.WaitGroup
for i := 0; i < *workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for p := range ports {
addr := net.JoinHostPort(*host, fmt.Sprint(p))
conn, err := net.DialTimeout("tcp", addr, *timeout)
if err != nil {
continue
}
conn.Close()
mu.Lock()
open = append(open, p)
mu.Unlock()
}
}()
}
for p := *from; p <= *to; p++ {
ports <- p
}
close(ports)
wg.Wait()
sort.Ints(open)
for _, p := range open {
fmt.Printf("%s:%d open\n", *host, p)
}
}go run scan.go -host 127.0.0.1 -from 1 -to 1024127.0.0.1:22 open
127.0.0.1:631 open
net.JoinHostPort handles IPv6 literals correctly by adding brackets. A mutex guards the shared slice, and sorting at the end fixes the nondeterministic order. The 100 workers keep the number of open sockets bounded; the default file descriptor limit of 1024 on many systems makes thousands of workers fail with "too many open files". Raise it with ulimit -n only if you must.
Reading the result
A closed port on a reachable host answers with a reset, so DialTimeout returns "connection refused" immediately. A port behind a dropping firewall sends nothing back, and the call waits the full timeout, which is why a scan of a filtered host is slow. Distinguish them with the error type:
if ne, ok := err.(net.Error); ok && ne.Timeout() {
// filtered or host down
}Pick the timeout from the network: 200 ms is enough on a LAN, and several seconds are needed across the internet. A short timeout on a distant host produces false "closed" results.
Grabbing a banner
To see what answers, read a few bytes after connecting, with a deadline: conn.SetReadDeadline(time.Now().Add(time.Second)) then conn.Read(buf). SSH and SMTP greet first; HTTP waits for a request. That is enough to confirm the service type without extra tools.
Where this does not apply
A connect scan is noisy: every probe completes a handshake and is logged by the target. It is a poor choice for stealth, and a good one for checking your own infrastructure. UDP services need protocol-specific probes. For serious auditing use a maintained tool like nmap. For a single check in a shell script, nc -z -w2 host port or Bash's /dev/tcp is shorter; see SSH connection refused for what the results mean when the port is SSH.
What the three outcomes of a dial mean
A TCP connection attempt ends in one of three ways, and the scanner should tell them apart. A successful connection means something is listening. An immediate error, "connection refused", means the host answered with a reset because nothing is listening on that port. A timeout means no answer came, and the usual cause is a firewall that drops packets silently, though a host that is down or a path that is lossy looks the same. Go reports the difference through the returned error: a timeout satisfies net.Error with Timeout() true, while a refusal is an *net.OpError wrapping a syscall error that you can compare with errors.Is(err, syscall.ECONNREFUSED). Reporting "open", "closed" and "filtered" is more useful than a list of open ports alone, since a wall of timeouts means a firewall is in the way and a wall of refusals means the host is reachable.
A full connect scan completes the TCP handshake and closes the connection, and the service may log each attempt. That is the honest limit of a program that uses ordinary sockets: it cannot send the half-open probes of specialized tools, which need raw sockets and administrator privileges.
Concurrency, descriptors and being polite
Scanning 65,535 ports one at a time with a one second timeout takes most of a day when ports are filtered, so the scanner needs concurrency, and concurrency needs a ceiling. Each in-flight dial holds a file descriptor, and the default limit on many systems is 1024, so a pool of a few hundred workers is the practical size unless you raise the limit with ulimit -n. Setting it too high shows up as errors saying "too many open files", which the scanner would wrongly report as closed ports unless it checks for them. A short timeout, between 300 and 1000 milliseconds, fits a local network, and a longer one is needed across the internet. Lower the rate on shared or fragile networks, since a burst of connection attempts can trip intrusion detection or exhaust a small device's connection table.
Only scan systems you own or have written permission to test. Port scanning someone else's network can violate their terms and the law in some places, and security teams treat it as hostile activity. Point the tool at your own hosts, a test machine or a lab.
Banners and what they reveal
After connecting, some services speak first. SSH, SMTP, FTP and many databases send a greeting, and reading it with a short deadline set by conn.SetReadDeadline identifies the service and version, which is useful for inventory and for finding old software. Others, such as HTTP, wait for the client, so send a minimal request, like a HEAD line with a host header, before reading. Limit the amount read, with io.LimitReader or a fixed buffer, so a hostile or misbehaving service cannot feed your program unlimited data, and print the result with non-printable bytes escaped, since a banner can contain control characters that mangle a terminal. Treat banners as hints and not facts: administrators change or remove them, and a service on a nonstandard port may be anything.
For TLS services, connect with tls.Dial and read the certificate's subject and expiry from the connection state, which turns a port scan into a certificate inventory with a few more lines.
Output that is useful to people and to scripts
Sort the results before printing, since concurrent workers finish in random order and an unsorted list is hard to compare between runs. Offer a plain mode that prints one open port per line for scripts, a table for people, and JSON for tools. Include the elapsed time and the number of ports probed, so a reader can judge how complete a run was. A timeout setting that is too short produces false "closed" results on a slow link, and a flag for the timeout, with the default printed in the help, makes that visible. Compare your results with a trusted scanner on a test host to check the logic, and expect small differences on ports where services rate-limit connections or accept and then drop them.
Keep it to your own machines, and when you scan, tell the owners of the network.
See the net.DialTimeout documentation and RFC 9293 (TCP).


