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 naive approach: regex (and why it fails)
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("DE123456789") # True (but might not exist) validate_vat("FR12345678901") # False (actually valid — FR allows letters!) validate_vat("EL123456789") # 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 requests library — the de facto standard for HTTP in Python. Install it with pip install requests.
# vat_validator.py import os import requests API_KEY = os.environ["ISVALID_API_KEY"] BASE_URL = "https://api.isvalid.dev" def validate_vat( vat_number: str, *, country_code: str | None = None, check_vies: bool = False, ) -> dict: """Validate a VAT number using the IsValid API. Args: vat_number: VAT number with or without country prefix (e.g. "DE123456789"). country_code: ISO 3166-1 alpha-2 code, inferred from prefix if omitted. check_vies: Query EU VIES registry for active registration. Returns: Validation result as a dictionary. Raises: requests.HTTPError: If the API returns a non-2xx status. """ params: dict[str, str] = {"value": vat_number} if country_code: params["countryCode"] = country_code if check_vies: params["checkVies"] = "true" response = requests.get( f"{BASE_URL}/v0/vat", params=params, headers={"Authorization": f"Bearer {API_KEY}"}, ) response.raise_for_status() return response.json() # ── Example usage ──────────────────────────────────────────────────────────── result = validate_vat("DE123456789", check_vies=True) 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=DE123456789"
With VIES registration check (slower — queries EU servers):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vat?value=DE123456789&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=123456789&countryCode=DE"
7. Understanding the response
Response for a valid German VAT number with VIES check enabled:
{ "valid": true, "normalized": "123456789", "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 789, de123456789, DE-123.456.789. 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
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.