IP Address Validation in Python — IPv4 & IPv6 Type Detection
IP addresses look deceptively simple but come in two vastly different formats with dozens of reserved ranges. Here's why regex fails, what the address types mean, and how to validate any IP address in a single Python API call.
In this guide
1. Why IP address validation matters
IP addresses are fundamental to every networked application. You encounter them in firewall rules, access control lists, rate limiting, geolocation lookups, audit logs, and API allow/deny lists. Accepting an invalid or misclassified IP address can open security holes, break routing logic, or produce misleading analytics.
Security — Firewalls and WAFs rely on accurate IP classification. Treating a public IP as private (or vice versa) can accidentally expose internal services or block legitimate users. If your application accepts user-supplied IP addresses (e.g., for allow-listing), you need to verify that they are syntactically valid and correctly classified.
Access control — Many applications restrict access based on IP ranges. Admin panels might only be accessible from private network addresses, while webhooks must come from known public IPs. Misidentifying an address type breaks these policies.
Geolocation — IP-to-location services only work with public addresses. Passing a private, loopback, or link-local address to a geolocation API returns meaningless results. Detecting the address type before the lookup saves API calls and avoids data quality issues.
Logging and analytics — Storing normalized, validated IP addresses ensures consistent log formats and accurate aggregation. An IPv6 address can be written in many equivalent forms — without normalization, the same address appears as multiple distinct entries in your analytics.
2. IPv4 vs IPv6 — the two standards
The Internet uses two IP address formats that look completely different and have different validation rules.
IPv4 — the familiar dotted-decimal format
IPv4 addresses consist of four octets (0-255) separated by dots, giving a 32-bit address space of roughly 4.3 billion addresses. Examples: 192.168.1.1, 10.0.0.1, 8.8.8.8.
The IPv4 address space was exhausted in 2011 (IANA) and regional registries followed soon after. This is the primary driver behind IPv6 adoption.
IPv6 — the 128-bit successor
IPv6 addresses consist of eight groups of four hexadecimal digits separated by colons, giving a 128-bit address space (3.4 × 1038 addresses). Groups of consecutive zeros can be compressed using :: shorthand.
| Form | Example |
|---|---|
| Full expanded | 2001:0db8:0000:0000:0000:0000:0000:0001 |
| Compressed | 2001:db8::1 |
| Loopback | ::1 |
| IPv4-mapped | ::ffff:192.168.1.1 |
The transition challenge
Both protocols coexist today. Most networks run dual-stack (IPv4 + IPv6 simultaneously), and transition mechanisms like IPv4-mapped IPv6 addresses (::ffff:192.168.1.1) add further complexity. Your validation logic must handle both formats correctly.
3. IP address types and reserved ranges
Not all IP addresses are created equal. Various ranges are reserved for specific purposes, and knowing the type is critical for security and routing decisions.
IPv4 reserved ranges
| Type | Range | RFC | Purpose |
|---|---|---|---|
| Private | 10.0.0.0/8 | RFC 1918 | Large internal networks |
| Private | 172.16.0.0/12 | RFC 1918 | Medium internal networks |
| Private | 192.168.0.0/16 | RFC 1918 | Home and small office networks |
| Loopback | 127.0.0.0/8 | RFC 1122 | Localhost communication |
| Link-local | 169.254.0.0/16 | RFC 3927 | Auto-configuration when no DHCP |
| Multicast | 224.0.0.0/4 | RFC 5771 | One-to-many communication |
| Broadcast | 255.255.255.255/32 | RFC 919 | All hosts on local network |
IPv6 reserved ranges
4. Why simple regex fails
The first instinct is to reach for a regular expression. Something like this:
import re # A common (but broken) IPv4 regex ipv4_regex = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") # Looks like it works: ipv4_regex.match("192.168.1.1") # match ✓ ipv4_regex.match("8.8.8.8") # match ✓ # But accepts invalid addresses: ipv4_regex.match("999.999.999.999") # match ✗ (octets > 255) ipv4_regex.match("01.02.03.04") # match ✗ (leading zeros — octal in some parsers) ipv4_regex.match("192.168.001.001") # match ✗ (leading zeros) # And tells you nothing about: # - Is it private or public? # - Is it a loopback address? # - What's the normalized form?
IPv6 is even harder — the compressed notation with :: makes regex validation practically impossible to get right:
# IPv6 regex is notoriously complex and fragile ipv6_regex = re.compile(r"^[0-9a-fA-F:]+$") # Accepts garbage: ipv6_regex.match(":::::::") # match ✗ (not valid) ipv6_regex.match("gggg::1") # no match ✓ (but a stricter regex might miss edge cases) # Cannot handle: # - IPv4-mapped addresses (::ffff:192.168.1.1) # - Multiple :: in the same address (invalid but hard to detect) # - Zone IDs (fe80::1%eth0) # - Whether the address is global-unicast vs link-local vs multicast
The fundamental issue is that IP address validation requires semantic understanding, not just syntactic pattern matching. You need to verify that octets are in range, that :: appears at most once, that IPv4-mapped suffixes are valid, and then classify the address into its correct type. A regex cannot do all of this reliably.
5. The right solution: one API call
The IsValid IP Address API validates both IPv4 and IPv6 addresses in a single GET request. It handles format validation, version detection, type classification, and normalization — and returns the expanded form for IPv6 addresses.
Pass any IP address string — IPv4 or IPv6, compressed or expanded — and the API returns the validated, classified, and normalized result.
Full parameter reference and response schema: IP Address Validation API docs →
6. Python code example
Using the isvalid-sdk Python SDK or the popular requests library. Install with pip install isvalid-sdk or pip install requests.
# ip_validator.py import os from isvalid_sdk import IsValidConfig, create_client iv = create_client(IsValidConfig(api_key=os.environ["ISVALID_API_KEY"])) # ── Validate an IPv4 address ──────────────────────────────────────────────── v4 = iv.net.ip("192.168.1.1") print(v4["valid"]) # True print(v4["version"]) # 4 print(v4["type"]) # 'private' print(v4["isPrivate"]) # True print(v4["isLoopback"]) # False print(v4["normalized"]) # '192.168.1.1' # ── Validate an IPv6 address ──────────────────────────────────────────────── v6 = iv.net.ip("2001:0db8::1") print(v6["valid"]) # True print(v6["version"]) # 6 print(v6["expanded"]) # '2001:0db8:0000:0000:0000:0000:0000:0001' # ── Check for loopback ────────────────────────────────────────────────────── lo = iv.net.ip("127.0.0.1") print(lo["type"]) # 'loopback' print(lo["isLoopback"]) # True print(lo["isPrivate"]) # False
In a Flask or Django middleware context, you might use it like this:
# middleware.py (Flask) from functools import wraps from flask import request, jsonify, g def validate_client_ip(f): @wraps(f) def decorated(*args, **kwargs): client_ip = ( request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr ) try: ip_check = validate_ip(client_ip) except Exception: return jsonify({"error": "IP validation service unavailable"}), 502 if not ip_check["valid"]: return jsonify({"error": "Invalid client IP"}), 400 # Attach validated IP info to Flask's request context g.client_ip = ip_check["normalized"] g.ip_version = ip_check["version"] g.ip_type = ip_check["type"] g.ip_is_private = ip_check["isPrivate"] return f(*args, **kwargs) return decorated @app.route("/admin") @validate_client_ip def admin_panel(): # Only allow access from private networks if not g.ip_is_private: return jsonify({"error": "Access denied — private network only"}), 403 return jsonify({"message": "Welcome to the admin panel"})
normalized field when storing IP addresses. This ensures consistent formatting — especially important for IPv6, where the same address can be written in many equivalent ways.7. cURL example
Validate an IPv4 address:
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/net/ip?value=192.168.1.1"
Validate an IPv6 address:
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/net/ip?value=2001%3A0db8%3A%3A1"
Validate a public DNS resolver:
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/net/ip?value=8.8.8.8"
8. Understanding the response
Private IPv4 address:
{ "valid": true, "version": 4, "type": "private", "isPrivate": true, "isLoopback": false, "normalized": "192.168.1.1" }
IPv6 address with expanded form:
{ "valid": true, "version": 6, "type": "global-unicast", "isPrivate": false, "isLoopback": false, "normalized": "2001:db8::1", "expanded": "2001:0db8:0000:0000:0000:0000:0000:0001" }
Invalid IP address:
{ "valid": false }
| Field | Type | Description |
|---|---|---|
| valid | boolean | Whether the IP address is syntactically valid |
| version | number | IP version: 4 for IPv4, 6 for IPv6 |
| type | string | Address type — IPv4: public, private, loopback, link-local, multicast, broadcast; IPv6: global-unicast, loopback, link-local, unique-local, multicast |
| isPrivate | boolean | Whether the address is in a private/non-routable range |
| isLoopback | boolean | Whether the address is a loopback address (127.x.x.x or ::1) |
| normalized | string | The canonical normalized form of the address |
| expanded | string | Full expanded IPv6 representation with all groups and leading zeros (IPv6 only) |
expanded field is only present for IPv6 addresses. It shows the full 8-group, zero-padded form — useful for storage, comparison, and firewall rule generation.9. Edge cases to handle
IPv4-mapped IPv6 addresses
Addresses like ::ffff:192.168.1.1 embed an IPv4 address inside an IPv6 wrapper. These are common in dual-stack server environments. The API recognizes these and classifies them correctly.
mapped = iv.net.ip("::ffff:192.168.1.1") print(mapped["valid"]) # True print(mapped["version"]) # 6 print(mapped["type"]) # 'private' (classified by the embedded IPv4)
Compressed IPv6 notation
The :: shorthand can appear anywhere in an IPv6 address but only once. The API validates this rule and returns both the compressed (normalized) and fully expanded forms.
compressed = iv.net.ip("fe80::1") print(compressed["normalized"]) # 'fe80::1' print(compressed["expanded"]) # 'fe80:0000:0000:0000:0000:0000:0000:0001' # Invalid: double :: is rejected bad = iv.net.ip("fe80::1::2") print(bad["valid"]) # False
CIDR notation
CIDR notation like 192.168.1.0/24 represents a network range, not a single host address. The API validates individual IP addresses, not CIDR ranges. Strip the prefix length before validating if you need to check the network address itself.
Zone IDs
IPv6 link-local addresses sometimes include a zone ID (e.g., fe80::1%eth0). Zone IDs are interface-specific and not part of the address itself. Strip the zone ID (everything after %) before passing the address to the API.
Leading zeros in IPv4
Some parsers treat leading zeros as octal notation: 010.010.010.010 could mean 8.8.8.8 (octal) or 10.10.10.10 (decimal). The API handles this ambiguity and returns the correct normalized form.
10. Summary
See also
Validate IP addresses instantly
Free tier includes 100 API calls per day. No credit card required. Supports IPv4 and IPv6 with full type detection.