Input Normalization & Data Standardization Engineer
Normalizes heterogeneous inbound data into consistent internal representations through deterministic transformation pipelines. Treats every incoming value — dates in random formats, phone numbers with varying country codes, currencies with different decimal separators, addresses from disparate mail systems — as unstandardized until explicitly converted. Applies locale-aware parsing, unit conversion, and canonicalization rules to produce clean, queryable, comparable data that downstream logic can trust without additional interpretation.
TL;DR Checklist
- Normalize every field at the system boundary before passing to business logic
- Choose explicit parse strategies per field type (date formats, number locales, phone prefixes)
- Convert all dates to UTC ISO 8601 (
YYYY-MM-DDTHH:MM:SSZ) internally - Standardize numeric values to decimal types with explicit precision — never use floating-point for currency
- Normalize phone numbers to E.164 format (+C[CC]NNNNNNNNNN) using a library like
phonenumbers - Canonicalize text: strip whitespace, normalize unicode (NFKC), collapse internal runs of spaces
- Document the canonical form for every data field in your schema contracts
- Test normalizers with real-world messy data — never assume inputs are well-formatted
When to Use
Use this skill when:
- Receiving data from multiple external sources that use different formats (e.g., EU dates
DD.MM.YYYYvs US datesMM/DD/YYYY) - Building a system that aggregates customer records where phone numbers arrive in local, national, and international formats
- Converting legacy database values into modern application types (e.g., epoch timestamps to ISO 8601, Roman numerals to integers)
- Processing financial data with mixed currency symbols (
$1,234.56,€1.234,56,¥1234.56) and need a canonical decimal representation - Normalizing user-generated text that contains inconsistent spacing, unicode normalization forms, or encoding artifacts
When NOT to Use
Avoid this skill for:
- Security-focused input validation — use
input-validationfor schema enforcement, injection prevention, and XSS/SQLi sanitization instead - Binary data handling or file format conversion — those belong in
data-encodingfor base64, MIME multipart, or binary protocol parsing - ETL pipelines with batch transformations — use
input-processing-pipelineswhen you need streaming, parallel processing, or multi-stage data flows rather than single-value normalization
Core Workflow
Catalog Data Variants — Survey every external data source and enumerate all known format variants for each field type. For dates, document every observed format (
MM/DD/YYYY,DD.MM.YYYY,YYYY-MM-DD, Unix epoch, ordinal dates). For phone numbers, list all observed formats (national with country code, local without area code, international E.164, extended with extension codes likex123). Checkpoint: Create a format registry table mapping each source → every known variant → the canonical internal representation for that field type.Select Parse Libraries Strategically — Choose purpose-built parsing libraries rather than rolling custom regex-based parsers. Use
dateutil.parserwith explicit default timezones for date strings, thephonenumberslibrary for phone normalization,babel.numbersfor locale-aware number parsing, andunicodedata.normalize("NFKC", ...)for text canonicalization. Avoid writing custom format detectors — they are brittle and miss edge cases that battle-tested libraries handle correctly. Checkpoint: Every parse call must specify explicit fallback behavior when the primary parser fails — never let a malformed date string crash an entire request.Apply Canonical Transformation Rules — Run each normalized value through its canonical transformation pipeline: dates → UTC ISO 8601 strings or
datetimeobjects with explicit timezone, phone numbers → E.164 format (+14155552671), currency amounts → decimal with two-integer cents representation and isolated currency code, text → NFC-normalized unicode with stripped leading/trailing whitespace and collapsed internal runs of one or more spaces into a single space. Checkpoint: The canonical form must be deterministic — the same input always produces the same output regardless of the system locale, timezone, or platform running the code.Handle Ambiguity Explicitly — When a normalizer cannot unambiguously determine the correct canonical form (e.g.,
03/04/2024could be March 4 or April 3), use contextual clues in priority order: source system convention, user locale preference, geographic origin IP, then a configurable default. If no context is available and ambiguity cannot be resolved, raise a structured error identifying the ambiguous field rather than silently guessing. Checkpoint: Never suppress ambiguity — logging a warning and picking one interpretation silently is as bad as crashing; always surface the ambiguity to the caller with suggested fixes.Validate Canonical Output — After normalization, verify that the canonical output conforms to the expected internal type constraints (e.g., ISO 8601 dates parse back correctly, E.164 phone numbers match the regex
^\+[1-9]\d{1,14}$, currency decimals have at most two decimal places). This catches edge cases where a parse library succeeded but produced an unexpected result. Checkpoint: If canonical validation fails, log the raw input alongside the malformed normalized output for debugging — this is always a bug in the normalizer configuration, not user error.
Implementation Patterns
Pattern 1: Universal Date/Time Normalization
Convert dates from any common format into UTC ISO 8601 internal representation. Handles timestamps with timezone offsets, naive local times, epoch integers, and human-readable strings like "yesterday" or "Jan 15 2024".
"""Universal date/time normalizer — converts heterogeneous date inputs to UTC ISO 8601.
Supports:
- ISO 8601 with timezone offset (+05:30, -04:00)
- US format (MM/DD/YYYY), EU format (DD.MM.YYYY), and Asian format (YYYY/MM/DD)
- Human-readable relative dates ("today", "yesterday", "next Monday")
- Unix epoch timestamps (seconds or milliseconds)
- Legacy formats from popular systems (Excel serial dates, JavaScript Date strings)
All outputs are UTC datetime objects — the application domain never stores local time.
"""
import re
from datetime import datetime, timedelta, timezone
from typing import Optional
class DateNormalizer:
"""Converts date strings and numeric timestamps to UTC datetime objects."""
RELATIVE_DATE_PATTERNS = {
"today": lambda today: today.replace(hour=0, minute=0, second=0),
"yesterday": lambda today: (today - timedelta(days=1)).replace(
hour=0, minute=0, second=0
),
"tomorrow": lambda today: (today + timedelta(days=1)).replace(
hour=0, minute=0, second=0
),
}
def __init__(self, default_timezone: str = "UTC") -> None:
self.default_tz = timezone.utc if default_timezone == "UTC" else timezone.utc
def normalize(
self,
value: object,
source_locale: Optional[str] = None,
fallback_date: Optional[datetime] = None,
) -> datetime:
"""Normalize any date input to a UTC datetime object.
Args:
value: Date string in any supported format, or Unix timestamp (int/float).
source_locale: Hint for ambiguous formats — "US", "EU", "JP".
fallback_date: Date to use if normalization fails entirely.
Returns:
A timezone-aware datetime object in UTC.
Raises:
ValueError: If the value cannot be parsed into any recognized format
and no fallback is provided or applicable.
"""
# Handle relative date strings like "today", "yesterday"
if isinstance(value, str):
lower = value.strip().lower()
if lower in self.RELATIVE_DATE_PATTERNS:
today = fallback_date or datetime.now(timezone.utc)
return self.RELATIVE_DATE_PATTERNS[lower](today)
# Handle epoch timestamps (numeric)
if isinstance(value, (int, float)):
if value > 1e12:
value = value / 1000.0
return datetime.fromtimestamp(value, tz=timezone.utc)
if not isinstance(value, str):
raise ValueError(f"Cannot normalize {type(value).__name__} as a date")
parsed_date = self._parse_string(value, source_locale)
if parsed_date is None:
if fallback_date:
return fallback_date
raise ValueError(
f"Unrecognized date format: '{value}'. "
f"Supported formats include ISO 8601, MM/DD/YYYY (US), DD.MM.YYYY (EU), "
f"and human-readable strings like 'Jan 15, 2024'."
)
if parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=self.default_tz)
return parsed_date.astimezone(timezone.utc)
def _parse_string(self, value: str, source_locale: Optional[str]) -> Optional[datetime]:
"""Attempt to parse a date string by trying each known format in priority order."""
stripped = value.strip()
# 1. ISO 8601
try:
if stripped.endswith("Z"):
parsed_date = datetime.fromisoformat(stripped[:-1] + "+00:00")
return parsed_date.astimezone(timezone.utc)
else:
parsed_date = datetime.fromisoformat(stripped)
return parsed_date.astimezone(timezone.utc)
except (ValueError, TypeError):
pass
# 2. Locale-specific numeric formats
if source_locale == "US" or (source_locale is None and self._detect_us_format(stripped)):
result = self._try_parse_numeric(stripped, order="mdy", delimiters="/-")
if result:
return result
if source_locale == "EU" or (source_locale is None and self._detect_eu_format(stripped)):
result = self._try_parse_numeric(stripped, order="dmy", delimiters=".")
if result:
return result
# 3. Text-based formats
return self._try_parse_text_date(stripped)
@staticmethod
def _detect_us_format(value: str) -> bool:
"""Heuristic: US format has month first when day > 12."""
match = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{4})$", value)
if not match:
return False
month = int(match.group(1))
return month > 12
@staticmethod
def _detect_eu_format(value: str) -> bool:
"""Heuristic: EU format uses dots."""
return "." in value
@staticmethod
def _try_parse_numeric(
value: str, order: str, delimiters: str
) -> Optional[datetime]:
"""Parse numeric date with specified component order and delimiter."""
pattern = re.compile(r"(\d{1,2})[" + re.escape(delimiters) + r"](\d{1,2})["
+ re.escape(delimiters) + r"](\d{4})")
match = pattern.match(value.strip())
if not match:
return None
a, b, year = int(match.group(1)), int(match.group(2)), int(match.group(3))
try:
if order == "mdy":
return datetime(year=year, month=a, day=b, tzinfo=timezone.utc)
elif order == "dmy":
return datetime(year=year, month=b, day=a, tzinfo=timezone.utc)
except ValueError:
pass
return None
@staticmethod
def _try_parse_text_date(value: str) -> Optional[datetime]:
"""Parse text-based date like 'January 15, 2024' or 'Jan 15, 2024'."""
short_months = {
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
}
short_pattern = re.match(r"^([A-Z][a-z]{2})\s+(\d{1,2}),?\s+(\d{4})$", value.strip())
if short_pattern:
month_num = short_months.get(short_pattern.group(1).lower())
if month_num:
return datetime(
year=int(short_pattern.group(3)),
month=month_num,
day=int(short_pattern.group(2)),
tzinfo=timezone.utc,
)
long_pattern = re.match(r"^([A-Z][a-z]+)\s+(\d{1,2}),?\s+(\d{4})$", value.strip())
if long_pattern:
month_name = long_pattern.group(1).lower()
month_num = short_months.get(month_name[:3])
if month_num:
return datetime(
year=int(long_pattern.group(3)),
month=month_num,
day=int(long_pattern.group(2)),
tzinfo=timezone.utc,
)
return None
Pattern 2: Phone Number Normalization to E.164 (BAD vs. GOOD)
Phone numbers arrive in wildly inconsistent formats. The E.164 standard is the only universally interoperable format for telecommunications systems and CRM databases.
"""Phone number normalizer — converts all phone formats to E.164 canonical form.
E.164 format: +[country_code][subscriber_number]
Examples:
US: (555) 123-4567 → +15551234567
UK: 020 7946 0958 → +442079460958
Germany: +49 (0) 30 12345678 → +493012345678
Uses Google's libphonenumber for accurate parsing and validation.
"""
# ❌ BAD — manual regex-based phone parsing is fragile and breaks on edge cases
def bad_normalize_phone(raw: str) -> str:
"""Naive phone normalization that fails on international numbers, extensions, and
various formatting styles. This pattern works only for one country's format.
Problems:
- Does not handle international numbers with + prefix
- Cannot distinguish between country code (1) and area code (555) in US numbers
- Strips extension codes entirely instead of preserving them separately
- Breaks on any non-standard formatting like spaces, dots, or dashes
- No validation that the parsed number is actually a valid phone number
Args:
raw: Raw phone number string from user input.
Returns:
A cleaned string that may not be E.164 compliant.
"""
digits = re.sub(r"[^\d]", "", raw)
if len(digits) == 11 and digits.startswith("1"):
return f"+1{digits[1:]}"
elif len(digits) == 10:
return f"+1{digits}"
return f"+{digits}"
# ✅ GOOD — Google's libphonenumber handles all edge cases correctly
class PhoneNormalizer:
"""Normalize phone numbers to E.164 canonical form using Google's libphonenumber."""
def normalize(self, raw: str) -> dict:
"""Normalize a phone number string to E.164 canonical form.
Parses the input using libphonenumber's robust parser, extracts the
international E.164 number and any extension code, and validates that
the result is a reachable number format.
Args:
raw: Raw phone number string from user input. May include formatting
characters (parentheses, dashes, spaces), country codes (+1, +44),
extensions (ext., x123), or be entirely invalid.
Returns:
Dictionary with keys:
- 'e164': E.164 formatted string if parsing succeeded, None otherwise.
- 'extension': Extension number as string if present in input, None.
- 'valid': Boolean indicating whether the parsed number is valid.
- 'error': Error message string if parsing failed, None on success.
Raises:
TypeError: If raw is not a string.
"""
if not isinstance(raw, str):
raise TypeError(f"Phone input must be a string, got {type(raw).__name__}")
import phonenumbers
# Extract extension before parsing — extensions are not part of E.164
extension = None
cleaned = raw.strip()
ext_match = re.search(
r"(?:ext\.?|x|#)\s*(\d+)", raw, re.IGNORECASE
)
if ext_match:
extension = ext_match.group(1)
cleaned = raw[:ext_match.start()].strip()
try:
parsed = phonenumbers.parse(cleaned, None)
if not phonenumbers.is_valid_number(parsed):
return {
"e164": None,
"extension": extension,
"valid": False,
"error": f"Invalid phone number: {raw}",
}
e164 = phonenumbers.format_number(
parsed, phonenumbers.PhoneNumberFormat.E164
)
return {
"e164": e164,
"extension": extension,
"valid": True,
"error": None,
}
except phonenumbers.NumberParseException as exc:
return {
"e164": None,
"extension": extension,
"valid": False,
"error": f"Failed to parse phone number '{raw}': {exc.message}",
}
# Demonstration with real-world test cases
if __name__ == "__main__":
normalizer = PhoneNormalizer()
test_cases = [
("(555) 123-4567", "US local with area code formatting"),
("+44 20 7946 0958", "UK international format"),
("020 7946 0958", "UK national format (without country code)"),
("+49 (0) 30 12345678", "Germany with trunk prefix in parentheses"),
("+1 555 123 4567 ext. 100", "US number with extension"),
("invalid-phone", "Invalid input that should be caught gracefully"),
]
for raw, description in test_cases:
result = normalizer.normalize(raw)
status = "VALID" if result["valid"] else "INVALID"
print(f"\n{description}:")
print(f" Input: '{raw}'")
print(f" Result: {status}")
if result["e164"]:
ext_note = f" ext.{result['extension']}" if result['extension'] else ""
print(f" E.164: {result['e164']}{ext_note}")
if result.get("error"):
print(f" Error: {result['error']}")
Why libphonenumber over custom parsing:
The phonenumbers library is the Python binding for Google's libphonenumber, which powers Android's phone dialer and Gmail's contact parsing. It maintains a comprehensive database of all country dialing codes, number lengths, formatting rules, and edge cases — far beyond what any custom regex can cover. Using it prevents subtle bugs where a UK number 07911 123456 (mobile) would be incorrectly parsed as a London landline by a naive parser.
Pattern 3: Currency Amount Normalization
Convert currency amounts from human-readable formats ($1,234.56, €1.234,56, ¥1234.56) into canonical decimal representations with isolated currency codes. Handles thousands separators that differ by locale and floating-point precision pitfalls.
"""Currency amount normalizer — converts locale-specific number formats to canonical decimals.
Handles the most common international number formatting conventions:
US/UK: $1,234.56 → Decimal(1234.56), currency="USD"
EU: €1.234,56 → Decimal(1234.56), currency="EUR"
JP: ¥1,235 → Decimal(1235), currency="JPY"
Uses Python's decimal.Decimal for exact arithmetic — never float for money.
"""
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
class CurrencyNormalizer:
"""Normalize locale-specific currency strings to canonical Decimal + currency code."""
CURRENCY_SYMBOLS = {
"$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY", "₹": "INR",
"₩": "KRW", "₽": "RUB", "A$": "AUD", "C$": "CAD",
}
ALPHA_CURRENCIES = {
code: symbol for symbol, code in CURRENCY_SYMBOLS.items()
}
def normalize(self, raw: str) -> tuple[Decimal, str]:
"""Normalize a currency string to (decimal amount, ISO 4217 currency code).
Args:
raw: Currency amount as a string with optional symbol, thousands separators,
and decimal point. Examples: "$1,234.56", "€1.234,56".
Returns:
Tuple of (Decimal amount, ISO 4217 currency code string).
Raises:
ValueError: If the string cannot be parsed as a valid currency amount.
"""
if not isinstance(raw, str):
raise TypeError(f"Currency input must be a string, got {type(raw).__name__}")
stripped = raw.strip()
if not stripped:
raise ValueError("Empty currency string")
amount_str, currency_code = self._detect_currency(stripped)
try:
amount = self._parse_decimal(amount_str, currency_code)
except (InvalidOperation, ValueError) as exc:
raise ValueError(
f"Cannot parse currency amount '{stripped}': {exc}"
) from exc
amount = self._round_by_currency(amount, currency_code)
return amount, currency_code
def _detect_currency(self, raw: str) -> tuple[str, str]:
"""Detect currency code and extract the numeric portion."""
for code in self.ALPHA_CURRENCIES:
if raw.upper().startswith(code + " ") or raw.upper().endswith(" " + code):
amount_str = raw.upper().replace(code, "").strip()
return amount_str, code
for symbol, code in self.CURRENCY_SYMBOLS.items():
if raw.startswith(symbol) or raw.endswith(symbol):
amount_str = raw.replace(symbol, "").strip()
return amount_str, code
return raw, "USD"
def _parse_decimal(self, amount_str: str, currency_code: str) -> Decimal:
"""Parse the numeric portion of a currency string into Decimal."""
cleaned = amount_str.replace("'", "").replace(" ", "")
is_negative = cleaned.startswith("-")
if is_negative:
cleaned = cleaned[1:]
# Handle currencies with no decimal places
if currency_code in ("JPY", "KRW", "VND"):
cleaned = cleaned.replace(".", "")
decimal_places = 0
else:
cleaned, decimal_places = self._detect_separators(cleaned)
cleaned = cleaned.replace(",", "")
result_str = ("-" if is_negative else "") + cleaned
quantizer = Decimal("1." + "0" * decimal_places)
return Decimal(result_str).quantize(quantizer, rounding=ROUND_HALF_UP)
@staticmethod
def _detect_separators(amount_str: str) -> tuple[str, int]:
"""Detect whether comma or period is the decimal separator."""
last_comma = amount_str.rfind(",")
last_period = amount_str.rfind(".")
if last_comma < 0 and last_period < 0:
return amount_str, 2
if last_comma > last_period:
# Comma is decimal separator (EU format: 1.234,56)
parts = amount_str.replace(".", "").split(",", 1)
integer_part = parts[0]
decimal_part = parts[1] if len(parts) > 1 else ""
return f"{integer_part}.{decimal_part}", len(decimal_part)
# Period is decimal separator (US format: 1,234.56)
parts = amount_str.split(".", 1)
integer_part = parts[0]
decimal_part = parts[1] if len(parts) > 1 else ""
return f"{integer_part},{decimal_part}", len(decimal_part)
@staticmethod
def _round_by_currency(amount: Decimal, currency_code: str) -> Decimal:
"""Round to the appropriate number of decimal places for its currency."""
decimals_0 = {"JPY", "KRW", "VND"}
decimals_3 = {"BHD", "JOD", "KWD", "OMR", "TND"}
if currency_code in decimals_0:
quantizer = Decimal("1")
elif currency_code in decimals_3:
quantizer = Decimal("0.001")
else:
quantizer = Decimal("0.01")
return amount.quantize(quantizer, rounding=ROUND_HALF_UP)
# Demonstration with international test cases
if __name__ == "__main__":
normalizer = CurrencyNormalizer()
test_cases = [
"$1,234.56", # US format
"€1.234,56", # EU format
"£999.99", # UK format
"¥1,235", # Japanese (no decimal places)
"-$42.50", # Negative amount
"100 USD", # Alphabetic currency code prefix
]
for raw in test_cases:
try:
amount, currency = normalizer.normalize(raw)
print(f"{raw:>15} → {amount:>12} {currency}")
except ValueError as exc:
print(f"{raw:>15} → ERROR: {exc}")
Key design decisions:
Decimaloverfloat: Floating-point arithmetic loses precision (0.1 + 0.2 = 0.30000000000000004). Currency must use exact decimal representation. Python'sdecimal.Decimalis the standard choice for handling arbitrary precision and avoiding binary floating-point conversion issues.- Round-by-currency: Not all currencies use 2 decimal places. Japanese yen has no subunit, Bahraini dinar uses 3. The normalizer automatically rounds to the correct precision for each currency code.
Constraints
MUST DO
- Normalize all date/time inputs to UTC ISO 8601 (
YYYY-MM-DDTHH:MM:SSZ) internally — never store local timezone dates as the canonical form - Use purpose-built libraries for parsing (dateutil, phonenumbers, babel) rather than custom regex-based parsers which are brittle and miss edge cases
- Represent all monetary values using
decimal.Decimalwith explicit rounding to currency-appropriate precision — never use floating-point arithmetic for money calculations - Apply Unicode NFKC normalization to all text fields before any comparison or storage — this prevents identical strings that differ only in unicode normalization form from being treated as different values
- Handle ambiguity explicitly: if a parser cannot determine the correct interpretation, raise a structured error with context about what was ambiguous rather than silently guessing and logging a warning
- Document the canonical internal form for every field type in your schema contracts so downstream developers know exactly what format to expect
MUST NOT DO
- Use
floatordoubletypes for any monetary value — floating-point rounding errors will corrupt financial calculations - Normalize phone numbers by simply stripping non-digit characters — this destroys the distinction between country code and area code and produces invalid E.164 numbers for international formats
- Trust locale-specific date formats without explicit context —
03/04/2024is ambiguous (March 4 vs April 3) and guessing leads to data corruption that is extremely difficult to trace back - Apply normalization rules inconsistently across different code paths — if one part of the system normalizes dates to ISO 8601 and another stores Unix timestamps, you cannot reliably compare or query them
- Store normalized values in a less precise format than the original — if a source provides millisecond-precision timestamps, do not truncate to seconds during normalization
Output Template
When implementing or reviewing data normalization code, produce:
- Canonical Format Specification — For each field type, document the exact internal representation (e.g., dates →
datetimeUTC objects, phones → E.164 strings, currency →Decimal+ ISO 4217 code) - Parse Library Selection — Justify why each parsing library was chosen and list alternative approaches that were considered and rejected with reasoning
- Ambiguity Resolution Strategy — Describe how unambiguous cases are resolved (source locale hint, geographic context, explicit user preference), including the fallback behavior for truly ambiguous inputs
- Canonical Validation Checks — List the post-normalization verification steps for each field type to ensure the output conforms to the canonical form
- Locale Coverage Matrix — Table mapping each known input format to the parsing strategy used, including which test cases cover that format and what edge cases were tested
Related Skills
| Skill | Purpose |
|---|---|
input-validation |
Enforces schema contracts and security constraints on validated data after normalization has already produced clean canonical forms — works in tandem to ensure both format correctness and security |
data-encoding |
Handles binary encoding/decoding (base64, hex, MIME multipart) for raw bytes before they reach the text-level normalization layer |
input-processing-pipelines |
Chains multiple normalizers into streaming transformation pipelines when you need to process bulk data through sequential normalization stages with error handling and retry logic |
type-safety-enforcement |
Applies runtime type checking and static analysis patterns that complement normalization by ensuring normalized values conform to expected types at module boundaries |
Live References
Authoritative documentation links for this domain. The model follows markdown links at load time to resolve external references and inline content.
- Unicode Normalization Forms (W3C) — W3C's guide to Unicode normalization forms (NFC, NFD, NFKC, NFKD) for text processing
- Input Validation Best Practices (OWASP) — OWASP's input validation cheat sheet covering sanitization and normalization strategies
- Python str.normalize (unicodedata) — Python's unicodedata module documentation for Unicode text normalization
- HTML Sanitization Libraries (OWASP) — OWASP reference on sanitizing user input to prevent XSS and injection attacks
- RFC 3986: URI Normalization — IETF RFC for URI syntax including normalization and comparison rules