How to Validate EU VAT Numbers in Python
EU VAT validation is harder than it looks. Here's why regex won't save you — and how to get it right with a single API call.
In this guide
1. The problem with EU VAT validation
At first glance, validating a VAT number in a Python app seems trivial — just check the format, right? But EU VAT numbers span 27 member states, each with its own structure, length, character set, and checksum algorithm. And format validation is only half the story: a number that looks correct might still be unregistered, suspended, or belong to a dissolved company.
If your app invoices B2B customers in Europe, incorrect VAT handling carries real tax liability. This guide walks through the failure modes and the practical solution.
2. The EU VAT number regex — and why it fails for all 27 countries
The first thing most developers try is a simple regex. Something like this:
# DO NOT USE — this will reject valid VAT numbers import re EU_VAT_REGEX = re.compile( r"^(AT|BE|BG|HR|CY|CZ|DK|EE|FI|FR|DE|EL|HU|IE|IT|LV|LT|LU|MT|NL|PL|PT|RO|SK|SI|ES|SE)" r"[0-9A-Z]{8,12}$" ) def validate_vat(vat: str) -> bool: return bool(EU_VAT_REGEX.match(vat.upper().replace(" ", ""))) validate_vat("DE123456788") # True (but might not exist) validate_vat("FR12345678801") # False (actually valid — FR allows letters!) validate_vat("EL123456788") # False (Greece uses EL, not GR) validate_vat("IE1234567FA") # False (Ireland's format: digit + 5 digits + 2 letters)
EL prefix, not GR. Irish numbers end with letters. Spain uses a mix of letters and digits in a specific pattern.To handle all EU countries correctly, you'd need 27 separate regexes — plus checksum verification logic for many of them (Germany, Poland, Italy, Spain, etc. all use country-specific check digit algorithms).
3. Why VAT validation is genuinely hard
Every country has a different format
Here's a sample of just the EU member states:
| Country | Prefix | Format |
|---|---|---|
| Germany | DE | 9 digits |
| France | FR | 2 alphanumeric + 9 digits |
| Poland | PL | 10 digits |
| Ireland | IE | 7 digits + 1–2 letters |
| Greece | EL | 9 digits (prefix is EL, not GR) |
| Spain | ES | letter/digit + 7 digits + letter/digit |
| Netherlands | NL | 9 digits + B + 2 digits |
| Italy | IT | 11 digits |
Many countries require checksum verification
Germany, Poland, Netherlands, Italy, Spain, and others use modulus-based check digit algorithms. A number that matches the regex may still fail the checksum — which means it was never issued to anyone. Implementing these correctly requires country-specific arithmetic, not just pattern matching.
Format valid ≠ actually registered
Even a perfectly formatted, checksum-valid number might not belong to an active business. To confirm a company is currently VAT-registered in the EU, you need to query the VIES system (VAT Information Exchange System) — the official EU registry.
VIES has downtime and inconsistent responses
VIES is a federated system — each EU country runs its own node. Some national nodes go offline for maintenance, return timeouts, or temporarily return MS_UNAVAILABLE. Your code needs to handle partial failures gracefully: a VIES timeout does not mean the VAT number is invalid.
4. The right solution: one API call
Instead of maintaining 27 country-specific validators plus VIES integration, use the IsValid VAT API. A single GET request handles format validation, checksum verification, and optional VIES lookup — for 60+ countries.
Get your free API key at isvalid.dev. The free tier includes 100 calls per day — enough for most development and low-volume production use.
Full parameter reference and response schema: VAT Validation API docs →
5. Python code example
Using the isvalid-sdk Python SDK or the requests library. Install with pip install isvalid-sdk or pip install requests.
# vat_validator.py import os from isvalid_sdk import IsValidConfig, create_client iv = create_client(IsValidConfig(api_key=os.environ["ISVALID_API_KEY"])) # ── Example usage ──────────────────────────────────────────────────────────── result = iv.vat("DE123456788", check_vies=True, country_code="DE") if not result["valid"]: print("Invalid VAT format or checksum") elif result.get("vies", {}).get("checked") and not result["vies"]["valid"]: print("Format OK, but not found in VIES — may be suspended or not yet registered") elif result.get("vies", {}).get("checked") and result["vies"]["valid"]: print(f"Active EU VAT: {result['vies']['name']}") else: print(f"Format valid — {result['countryName']}")
In a checkout form handler, you might use it like this with Flask:
# app.py (Flask) from flask import Flask, request, jsonify from vat_validator import validate_vat app = Flask(__name__) @app.post("/checkout") def checkout(): data = request.get_json() try: vat_check = validate_vat(data["vat_number"], check_vies=True) except requests.RequestException: return jsonify(error="VAT validation service unavailable"), 502 if not vat_check["valid"]: return jsonify(error="Invalid VAT number"), 400 vies = vat_check.get("vies", {}) if vies.get("checked") and not vies.get("valid"): return jsonify(error="VAT number is not currently registered"), 400 # Zero-rate VAT for valid intra-EU B2B sale apply_vat_exemption( vat_number=data["vat_number"], company_name=vies.get("name"), ) return jsonify(success=True, vatExempt=True)
Or with Django REST Framework:
# views.py (Django REST Framework) from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from vat_validator import validate_vat import requests as http_requests class CheckoutView(APIView): def post(self, request): vat_number = request.data.get("vat_number") if not vat_number: return Response( {"error": "vat_number is required"}, status=status.HTTP_400_BAD_REQUEST, ) try: vat_check = validate_vat(vat_number, check_vies=True) except http_requests.RequestException: return Response( {"error": "VAT validation service unavailable"}, status=status.HTTP_502_BAD_GATEWAY, ) if not vat_check["valid"]: return Response( {"error": "Invalid VAT number"}, status=status.HTTP_400_BAD_REQUEST, ) return Response({"valid": True, "country": vat_check["countryName"]})
result["valid"]) from VIES registration status (result["vies"]["valid"]). A VIES failure does not mean the number is wrong — the national node might just be offline.6. cURL example
Format + checksum validation only (fast, no VIES call):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vat?value=DE123456788"
With VIES registration check (slower — queries EU servers):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vat?value=DE123456788&checkVies=true"
With explicit country code (useful when the number has no prefix):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vat?value=123456788&countryCode=DE"
7. Understanding the response
Response for a valid German VAT number with VIES check enabled:
{ "valid": true, "normalized": "123456788", "countryCode": "DE", "countryName": "Germany", "isEU": true, "vies": { "checked": true, "valid": true, "name": "EXAMPLE GMBH", "address": "MUSTERSTRASSE 1\n10115 BERLIN" } }
| Field | Type | Description |
|---|---|---|
| valid | boolean | Format and checksum are correct |
| normalized | string | Uppercased, spaces/hyphens removed, prefix stripped |
| countryCode | string | 2-letter code used for validation |
| countryName | string | Full country name |
| isEU | boolean | Whether the country is an EU member state |
| vies.checked | boolean | true if VIES was reachable and queried |
| vies.valid | boolean | Whether the number is actively registered in VIES |
| vies.name | string | null | Registered business name from VIES |
| vies.address | string | null | Registered business address from VIES |
When checkVies is omitted or false, the vies field is not included in the response. When VIES is unreachable, vies.checked is false and vies.reason is "unavailable".
8. Edge cases to handle
Suspended VAT numbers
A company can have its VAT registration suspended by the tax authority — the number remains syntactically valid and passes checksum, but VIES returns valid: false. Never zero-rate invoices based on format alone; always run a VIES check for B2B EU sales.
# Handle suspended / deregistered VAT vies = result.get("vies", {}) if result["valid"] and vies.get("checked") and not vies.get("valid"): # Format OK, but not in VIES — reject or flag for manual review raise ValueError("VAT number is not currently registered")
VIES downtime (national node unavailable)
When a national node is offline, the API returns vies.checked: false with vies.reason: "unavailable". Do not reject the transaction in this case — treat it as "format valid, VIES inconclusive" and consider retrying later or allowing the transaction with a note.
vies = result.get("vies", {}) if vies and not vies.get("checked"): reason = vies.get("reason") if reason == "unavailable": # VIES is temporarily down — do not reject, log and retry later logger.warning("VIES unavailable for %s", vat_number) return {"valid": result["valid"], "vies_status": "unavailable"} if reason == "not_eu": # Non-EU country — no VIES check possible, format-only validation return {"valid": result["valid"], "vies_status": "not_applicable"}
Input normalisation
Users enter VAT numbers in many formats: DE 123 456 788, de123456788, DE-123.456.788. The API strips spaces, hyphens, and dots automatically and uppercases the input. Pass the raw user input — no need to pre-process it.
Network failures in your code
Always wrap the API call in a try/except. A network timeout should not cause your checkout to crash — decide upfront whether to fail open or closed on API unavailability.
def validate_vat_safe( vat_number: str, **kwargs ) -> dict: try: return validate_vat(vat_number, **kwargs) except requests.RequestException as exc: # Log and decide: fail open (allow) or fail closed (reject) logger.error("VAT validation failed: %s", exc) return {"valid": None, "error": "validation_unavailable"}
Summary
Python integration notes
Pydantic V2 makes it straightforward to validate EU VAT Number as part of a data model. Use @field_validator or the Annotated pattern with AfterValidator to call the IsValid API inside the validator and raise aValueError on failure. The validator runs automatically whenever the model is instantiated — in a FastAPI request body, a SQLModel ORM model, or a standalone Pydantic parse. Initialise the API client as a module-level singleton so it is not re-created on every request.
FastAPI and Django integration
In FastAPI, inject the IsValid client viaDepends() so the samehttpx.AsyncClient connection pool is shared across all concurrent requests. Pair it with async route handlers to keep the event loop non-blocking during EU VAT Number validation. In Django, implement aclean() method on your model or form field to call the synchronous SDK client; wrap it insync_to_async() if you are using Django Channels or async views.
When processing a batch of EU VAT Number values — during a CSV import or a nightly reconciliation job — useasyncio.gather() with a shared httpx.AsyncClientand an asyncio.Semaphoreto cap concurrency at the API rate limit. This brings total validation time from O(n) sequential to O(1) bounded by the pool size.
Handle httpx.HTTPStatusError andhttpx.RequestErrorseparately. A 422 from IsValid means the EU VAT Number is structurally invalid — surface this as a validation error to the user. A 503 or network error means the API is temporarily unavailable — retry with exponential backoff usingtenacity before falling back gracefully.
- Store
ISVALID_API_KEYin a.envfile and load it withpython-dotenvat startup - Use
pytest-asynciowithasyncio_mode = "auto"for testing async validation paths - Type-annotate validated fields with
NewType('VatNumber', str)for clarity in function signatures - Apply
.strip()and.upper()before validation — EU VAT Number values often arrive with stray whitespace or mixed case from user input
See also
Try VAT validation instantly
Free tier includes 100 API calls per day. No credit card required. Works for 60+ countries including all 27 EU member states.