Direct answer
Do not try to write one regex that fully validates IPv4 and IPv6 addresses. A permissive regex should only find candidate tokens; a standard-library address parser (Python's ipaddress, or an equivalent in another language) should validate and normalize them. Splitting the job this way avoids the two classic failure modes: a regex so strict it misses valid addresses, or one so permissive it accepts garbage like 999.999.999.999.
Approach
- Match IPv4 candidates with a loose digit-dot pattern, optionally followed by
:port.
- Match bracketed IPv6 (
[addr]:port), the only unambiguous way to attach a port to an IPv6 address, since IPv6 addresses themselves contain colons.
- Match bare (unbracketed) IPv6 candidates separately, using a negative lookbehind (
(?<!...), which matches only when the text immediately before is NOT one of the listed characters) and a negative lookahead ((?!...), the same idea for the text immediately after) so the pattern doesn't start or stop its match in the middle of a longer hex-and-colon run already covered by the bracketed branch above.
- Feed every candidate through
ipaddress.IPv4Address / ipaddress.IPv6Address, which rejects invalid octets and normalizes IPv6 to its RFC 5952 zero-compressed canonical form (str() on the parsed object).
python
import re
import ipaddress
IPV4_RE = re.compile(r'\b(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?\b')
BRACKETED_V6_RE = re.compile(r'\[([0-9A-Fa-f:]+)\](?::(\d{1,5}))?')
BARE_V6_RE = re.compile(r'(?<![0-9A-Fa-f:.\[])([0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7})(?![0-9A-Fa-f:.\]])')
def extract_addresses(line):
found = []
for m in BRACKETED_V6_RE.finditer(line):
addr_txt, port_txt = m.group(1), m.group(2)
try:
addr = ipaddress.IPv6Address(addr_txt)
except ValueError:
continue
found.append(('IPv6', str(addr), port_txt, m.span()))
for m in IPV4_RE.finditer(line):
addr_txt, port_txt = m.group(1), m.group(2)
try:
addr = ipaddress.IPv4Address(addr_txt)
except ValueError:
continue
found.append(('IPv4', str(addr), port_txt, m.span()))
covered = [f[3] for f in found]
for m in BARE_V6_RE.finditer(line):
span = m.span()
if any(span[0] >= c[0] and span[1] <= c[1] for c in covered):
continue
candidate = m.group(1)
try:
addr = ipaddress.IPv6Address(candidate)
found.append(('IPv6', str(addr), None, span))
continue
except ValueError:
pass
if ':' in candidate:
head, _, tail = candidate.rpartition(':')
if tail.isdigit():
try:
addr = ipaddress.IPv6Address(head)
found.append(('IPv6-ambiguous', str(addr), tail, span))
except ValueError:
pass
return found
lines = [
"2024-01-01T00:00:00Z connect from 192.0.2.1:8080 to service",
"2024-01-01T00:00:01Z peer 2001:db8::1:443 negotiating tls",
"2024-01-01T00:00:02Z peer [2001:db8::1]:443 negotiating tls",
"2024-01-01T00:00:03Z bad token 999.999.999.999 in payload",
"2024-01-01T00:00:04Z full form 2001:0db8:0000:0000:0000:0000:0000:0001 seen",
]
for line in lines:
print(line)
for kind, addr, port, span in extract_addresses(line):
print(f" -> {kind}: {addr}" + (f" port={port}" if port else ""))
Worked example
Output on five representative log lines:
2024-01-01T00:00:00Z connect from 192.0.2.1:8080 to service
-> IPv4: 192.0.2.1 port=8080
2024-01-01T00:00:01Z peer 2001:db8::1:443 negotiating tls
-> IPv6: 2001:db8::1:443
2024-01-01T00:00:02Z peer [2001:db8::1]:443 negotiating tls
-> IPv6: 2001:db8::1 port=443
2024-01-01T00:00:03Z bad token 999.999.999.999 in payload
2024-01-01T00:00:04Z full form 2001:0db8:0000:0000:0000:0000:0000:0001 seen
-> IPv6: 2001:db8::1
The interesting case is the third line: 2001:db8::1:443. A naive "split on the last colon for a port" rule would read this as address 2001:db8::1 with port 443, but 443 in hexadecimal is also a perfectly valid last group of an IPv6 address, so ipaddress.IPv6Address("2001:db8::1:443") parses successfully as a complete, valid 128-bit address. There is no way to tell which the log author meant from the text alone. That is exactly why RFC 3986 requires brackets, [2001:db8::1]:443, when a port follows an IPv6 host: the bracket is the only unambiguous separator, and the fourth line above shows the parser resolving it correctly once brackets are present.
Key points
- Validate, don't just match:
999.999.999.999 matches a naive \d{1,3}(\.\d{1,3}){3} pattern but fails ipaddress.IPv4Address, which enforces each octet is 0 to 255.
- Normalize through the standard library, not by hand:
2001:0db8:0000:0000:0000:0000:0000:0001 and 2001:db8::1 are the same address; only ipaddress reliably produces the canonical compressed form.
- Bracket notation is the only safe way to disambiguate a trailing port on IPv6; if your logs never bracket IPv6 hosts, you cannot recover the port programmatically and should say so rather than guessing.
Complexity
Each candidate match and validation is O(length of the token); scanning a line of length n is O(n) since none of the character classes here cause catastrophic regex backtracking.
Edge cases
- IPv4-mapped IPv6 addresses (
::ffff:192.0.2.1) still validate correctly through ipaddress.IPv6Address.
- A raw colon-separated fragment like
00:00:00 inside a timestamp can match a loose hex-and-colon regex; this is caught downstream because ipaddress.IPv6Address("00:00:00") raises ValueError (an IPv6 address needs 8 groups, or a :: compression, not 3 bare groups), so validation silently discards the false positive.
- Zone IDs on link-local IPv6 (
fe80::1%eth0) are, in fact, standard ipaddress input as of Python 3.9 (ipaddress.IPv6Address("fe80::1%eth0") parses successfully and round-trips the zone in str()); don't strip the %zone suffix before validating on a modern interpreter, since ipaddress itself can be handed the whole token. The real gap is upstream of validation: BARE_V6_RE above doesn't include % in its character class, so it only ever captures fe80::1 and silently drops %eth0 before ipaddress gets a chance to see it. If zone IDs matter for your logs, extend the regex's trailing character class to allow a %[\w.-]+ suffix rather than relying on ipaddress to reject or accept the untouched token. (A link-local address is only valid on the local network segment, not routable beyond it; the zone ID after the % says which network interface it applies to, since the same link-local address can exist on more than one interface at once.)
Trade-offs & pitfalls
The common mistake is trying to encode the full IPv6 grammar (8 groups, one :: compression, embedded IPv4 tail forms) directly in a regex. It is technically possible but produces a pattern that is nearly unreadable and still gets edge cases wrong. Letting the regex be permissive and pushing correctness to a real parser is both simpler and more correct; the cost is a second pass over each candidate, which is negligible next to a log pipeline's overall I/O cost. A related mistake worth naming explicitly: a shared character class like [0-9a-fA-F:.%] for "anything IP-like" will also match ordinary hex-looking words (letters a-f) and will swallow a trailing :port into what it thinks is one IPv6 token, silently dropping the whole match when validation then rejects the combined string. Keeping the IPv4 and bracketed-IPv6 branches structurally separate, as above, avoids that trap.