How to Build a Port Scanner in Python (Lab Walkthrough)

Short answer: You build a Python port scanner by opening a TCP socket for each target port, attempting connect_ex(), and reading the return code — 0 means open, anything else means closed or filtered. Add threading to make it fast, and send a banner request to identify services.
TL;DR: A port scanner is a connect loop over a range of ports. The Python
socketmodule gives you everything you need. Threading cuts scan time from minutes to seconds. This walkthrough builds the full scanner in stages so you understand every line.Key Takeaways -
socket.connect_ex()returns0for open ports without raising an exception — cleaner thanconnect()for scanners. - A single-threaded scanner against 1,000 ports with a 1-second timeout takes ~1,000 seconds. Threading brings it to seconds. - Banner grabbing — sending a probe after connection — reveals service names and versions without running Nmap's-sV. - Always scan only authorized targets. Run this against your own lab VM or a purpose-built CTF range. - Building this from scratch means you understand what commercial tools do internally — which makes you better at interpreting their output.
What a port scanner actually does

Before writing a line of code, understand the mechanics. A TCP port scanner attempts to complete (or begin) a TCP connection to each port on a target host. The kernel reports whether the connection succeeded, was refused, or timed out. That response maps to three states:
- Open — something is listening; the connection completed
- Closed — the host responded with a RST (connection refused)
- Filtered — no response after the timeout; a firewall is probably dropping the packet
This is a full-connect scan: it completes the TCP handshake. It is less stealthy than the SYN scan described in network reconnaissance for penetration testers, but it requires no raw socket privileges and works from any user account. For a lab, it is exactly what you want.
connect_ex() returns 0 only for open ports.| Port state | TCP response | connect_ex() return |
Scanner output |
|---|---|---|---|
| Open | SYN/ACK | 0 |
Report open |
| Closed | RST | Non-zero (errno) | Skip or report closed |
| Filtered | None (timeout) | Non-zero (errno 110) | Skip or report filtered |
Prerequisites

- Python 3.8 or later (check with
python3 --version) - A lab target — your own VM, a local Docker container, or a sanctioned CTF range
- No third-party libraries needed for the core scanner;
concurrent.futuresis in the standard library
All code in this post is for authorized lab use only. Scanning hosts without permission is illegal in most jurisdictions.
Step 1: The minimal scanner (single-threaded)

Start with the simplest possible version. Understand it completely before adding complexity.
import socket
def scan_port(host: str, port: int, timeout: float = 1.0) -> bool:
"""
Attempt a TCP connection to host:port.
Returns True if the port is open, False otherwise.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
result = sock.connect_ex((host, port))
return result == 0
def scan_range(host: str, start: int, end: int) -> None:
"""Scan a range of ports and print open ones."""
print(f"Scanning {host} ports {start}–{end}")
for port in range(start, end + 1):
if scan_port(host, port):
print(f" [OPEN] port {port}")
if __name__ == "__main__":
scan_range("127.0.0.1", 1, 1024)
What each line does:
socket.AF_INET, socket.SOCK_STREAM— IPv4, TCP (stream socket)settimeout(1.0)— wait at most 1 second per port before moving onconnect_ex()— attempts the connection, returns an error code instead of raising an exceptionresult == 0— zero means success (EISCONN / no error), so the port is open
Run this against 127.0.0.1 on your lab machine and you will see whatever services are listening locally.
Step 2: Add threading for speed
A 1-second timeout per port × 1,024 ports = 17 minutes minimum, single-threaded. Threading collapses this to seconds.
import socket
import concurrent.futures
from typing import List
def scan_port(host: str, port: int, timeout: float = 1.0) -> tuple[int, bool]:
"""Return (port, is_open)."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
result = sock.connect_ex((host, port))
return port, result == 0
def threaded_scan(host: str, ports: List[int], max_workers: int = 100) -> List[int]:
"""
Scan a list of ports using a thread pool.
Returns a sorted list of open ports.
"""
open_ports = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(scan_port, host, port): port for port in ports}
for future in concurrent.futures.as_completed(futures):
port, is_open = future.result()
if is_open:
open_ports.append(port)
return sorted(open_ports)
if __name__ == "__main__":
target = "192.168.1.10" # replace with your lab target
port_list = list(range(1, 1025))
results = threaded_scan(target, port_list)
print(f"\nOpen ports on {target}:")
for p in results:
print(f" {p}")
ThreadPoolExecutor manages 100 concurrent threads. Each thread runs scan_port() independently. as_completed() processes results as they arrive. For 1,024 ports with 100 threads, the effective scan time drops to roughly (1024/100) × timeout = ~10 seconds. (This is a theoretical floor; actual scan time will be higher depending on network latency, timeout configuration, and system thread scheduling.)
Step 3: Add banner grabbing
Knowing a port is open is useful. Knowing what is listening there is better. Many services send a banner — a greeting string — immediately on connection.
import socket
def grab_banner(host: str, port: int, timeout: float = 2.0) -> str:
"""
Connect and attempt to read an initial banner from the service.
Returns the banner string, or an empty string if none received.
"""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
sock.connect((host, port))
# Some services (HTTP) need a probe before they send data
try:
sock.send(b"HEAD / HTTP/1.0\r\n\r\n")
except Exception:
pass
banner = sock.recv(1024).decode("utf-8", errors="replace").strip()
return banner
except Exception:
return ""
Banner grabbing is the same technique Nmap uses for version detection (-sV). HTTP servers return their Server: header; SSH servers announce their version; FTP servers send a 220 greeting. Even a partial banner narrows your fingerprinting considerably.
Step 4: The complete scanner with CLI
Putting it all together with argument parsing:
#!/usr/bin/env python3
"""
simple_scanner.py — TCP port scanner for lab and CTF use.
Usage: python3 simple_scanner.py <host> [-p PORT_RANGE] [-t THREADS]
AUTHORIZED USE ONLY. Only scan systems you own or have permission to test.
"""
import argparse
import socket
import concurrent.futures
from typing import List, Tuple
def scan_port(host: str, port: int, timeout: float = 1.0) -> Tuple[int, bool]:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
return port, sock.connect_ex((host, port)) == 0
def grab_banner(host: str, port: int, timeout: float = 2.0) -> str:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
sock.connect((host, port))
try:
sock.send(b"HEAD / HTTP/1.0\r\n\r\n")
except Exception:
pass
return sock.recv(1024).decode("utf-8", errors="replace").strip()
except Exception:
return ""
def parse_ports(port_arg: str) -> List[int]:
"""Parse '80', '1-1024', or '22,80,443' into a list of ints."""
ports = []
for part in port_arg.split(","):
if "-" in part:
start, end = part.split("-", 1)
ports.extend(range(int(start), int(end) + 1))
else:
ports.append(int(part))
return ports
def main():
parser = argparse.ArgumentParser(description="Simple TCP port scanner (lab use only)")
parser.add_argument("host", help="Target hostname or IP")
parser.add_argument("-p", "--ports", default="1-1024",
help="Port range: '80', '1-1024', '22,80,443' (default: 1-1024)")
parser.add_argument("-t", "--threads", type=int, default=100,
help="Thread pool size (default: 100)")
parser.add_argument("-b", "--banner", action="store_true",
help="Attempt banner grabbing on open ports")
args = parser.parse_args()
try:
target_ip = socket.gethostbyname(args.host)
except socket.gaierror:
print(f"[!] Cannot resolve host: {args.host}")
return
ports = parse_ports(args.ports)
print(f"[*] Scanning {args.host} ({target_ip}) — {len(ports)} ports, {args.threads} threads")
open_ports = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.threads) as executor:
futures = {executor.submit(scan_port, target_ip, p): p for p in ports}
for future in concurrent.futures.as_completed(futures):
port, is_open = future.result()
if is_open:
open_ports.append(port)
open_ports.sort()
print(f"\n{'PORT':<8}{'STATE':<10}{'BANNER' if args.banner else ''}")
print("-" * (40 if args.banner else 20))
for port in open_ports:
banner = grab_banner(target_ip, port) if args.banner else ""
banner_short = banner[:50].replace("\n", " ") if banner else ""
print(f"{port:<8}{'open':<10}{banner_short}")
print(f"\n[*] Scan complete. {len(open_ports)} open port(s) found.")
if __name__ == "__main__":
main()
Example usage in your lab:
# Basic scan of top 1024 ports
python3 simple_scanner.py 192.168.1.10
# Scan specific ports with banner grabbing
python3 simple_scanner.py 192.168.1.10 -p 21,22,80,443,3306 -b
# Wider scan with more threads
python3 simple_scanner.py 192.168.1.10 -p 1-65535 -t 200
How this compares to Nmap
Building your own scanner teaches you exactly what Nmap does internally. The differences are important to understand:
| Feature | Your Python scanner | Nmap |
|---|---|---|
| Scan type | Full TCP connect | SYN (half-open) by default |
| Privilege required | User-level | Root for SYN scan |
| UDP scanning | Not implemented | Full support |
| OS detection | No | Yes (-O) |
| NSE scripts | No | Full scripting engine |
| Speed at scale | Good (threaded) | Excellent (raw sockets) |
| Stealth | Low (completes handshake) | Higher (SYN only) |
Your scanner is a learning tool and a useful component in larger Python automation. For production-quality scanning with full OS/version detection, Nmap (or python-nmap as a wrapper) is the right tool. For understanding what is happening at every step, and for building custom scan logic that Nmap does not support, writing your own is irreplaceable. The python-nmap library lets you call Nmap from Python and parse its XML output — a common pattern in automated testing pipelines covered in Python automation for penetration testing.
Frequently asked questions
Why use connect_ex() instead of connect() for a port scanner?
connect() raises an exception when a connection fails, which means you need try/except around every port attempt. connect_ex() returns an error code instead of raising, which is cleaner for scanning — you just check if the return value is 0. Both are documented in the Python socket documentation.
How do I scan UDP ports with Python?
UDP scanning is harder because UDP is connectionless — there is no equivalent to a TCP handshake to confirm a port is open. You send a packet and either receive a response (open), get an ICMP port-unreachable (closed), or hear nothing (open or filtered). For lab purposes, Scapy gives you the most control over UDP probe crafting, or use Nmap's -sU flag.
Is banner grabbing the same as version detection?
They are related but not identical. Banner grabbing reads whatever the service sends immediately on connection. Version detection (like Nmap's -sV) sends protocol-specific probes matched against a database of signatures. Banner grabbing is simpler and often sufficient; version detection is more thorough but slower.
Can I use this scanner in a CTF?
Yes — CTF platforms explicitly authorize scanning of their lab ranges. Adjust the thread count to respect the platform's rules (some cap concurrent connections). For CTFs that require stealthy scanning, build on the socket concepts from Python socket programming for security professionals to craft SYN probes with Scapy instead.
ThreatBlock's offensive Python course takes you from Python basics to building real security tools hands-on.
About the author: Samaksh is Lead Instructor at ThreatBlock and holds the OSCP, CRTP, CRTO, and CEH certifications, with hands-on experience in penetration testing and red teaming. Read more on the author page · Editorial policy.
