Handling missing HTS codes in ETL pipelines
The exact failure this page solves: an ETL batch ingests commercial invoices, EDI 810/856 transmissions, or supplier feeds and one or more line items arrive with a null, empty-string, or structurally malformed Harmonized Tariff Schedule code. Left unhandled, the batch either aborts mid-run — stranding every downstream line item behind a single bad SKU — or silently coerces the gap into a default classification, which corrupts duty math and violates CBP reasonable-care obligations under 19 CFR 141.86. Neither outcome is acceptable in a production clearance environment. The correct behavior is deterministic: detect the gap during ingestion, hold the affected record without blocking the rest of the batch, attach a provisional conservative rate, and emit an immutable audit event before the line item ever reaches the Duty Formula Calculation Frameworks. This page gives you a single, runnable resolver that enforces that contract, plus the verification checklist and the encoding, temporal, and type-coercion gotchas that break naive implementations.
Prerequisites
This solution assumes a specific upstream pipeline state and toolchain. Confirm each before applying it:
- Python 3.10+ — the resolver uses structural typing (
dataclass(frozen=True)),match-friendly enums, andDecimalthroughout. Never usefloatfor money or duty rates; ISO 4217 minor-unit rounding is not representable in binary floating point. - An active tariff snapshot in memory or Redis. The resolver reads a point-in-time HTS snapshot keyed by digit-only code. That snapshot is produced upstream by Tariff Update Ingestion Pipelines and must expose
effective_date,expiry_date, andgeneral_rateper row, following the bitemporal contract defined in HTS Schedule Database Design. If your snapshot lacks validity windows, superseded-rate detection below silently no-ops. - A staged, read-committed source table. Line items must be materialized before resolution so the resolver is a pure function of
(item, snapshot, as_of). Do not resolve while a tariff swap is in flight. - A dead-letter/quarantine table and a broker-review queue already provisioned. This page routes to them; it does not create them.
- Structured logging configured (stdlib
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat, orstructlog) so every fallback event is greppable for audit.
Implementation
A single resolver owns the entire missing-code decision tree: format validation, snapshot lookup, temporal effectiveness, conservative fallback, and broker escalation. It never guesses a filable classification and never lets a gap reach duty computation unaudited.
import logging
import hashlib
from dataclasses import dataclass, field
from datetime import date, datetime
from enum import Enum
from typing import Optional, Dict, Any
from decimal import Decimal, ROUND_HALF_UP
# Structured logging so every fallback event is greppable for CBP audit.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("hts_etl_resolver")
class HtsStatus(Enum):
VALID = "VALID"
FALLBACK_HEURISTIC = "FALLBACK_HEURISTIC"
BROKER_REVIEW = "BROKER_REVIEW"
@dataclass(frozen=True)
class LineItem:
sku: str
description: str
quantity: int
unit_price: Decimal # Decimal, never float — ISO 4217 minor units
hts_code: Optional[str] = None # None, "", or malformed are all "missing"
origin_country: Optional[str] = None
@dataclass
class ClassificationResult:
line_item: LineItem
resolved_hts: str
status: HtsStatus
confidence_score: float
duty_rate: Decimal
audit_hash: str
timestamp: datetime = field(default_factory=lambda: datetime.now(tz=None))
class HtsResolver:
"""Deterministic missing-HTS resolver. Pure function of (item, snapshot, as_of)."""
def __init__(self, active_snapshot: Dict[str, Dict[str, Any]]):
self._snapshot = active_snapshot
self._placeholder = "9999999999" # provisional bucket, never filable
self._max_duty_rate = Decimal("0.2500") # conservative ceiling until reviewed
def _digits(self, code: Optional[str]) -> Optional[str]:
# Treat None AND empty string AND non-digit as "missing" — the three
# shapes malformed upstream feeds actually deliver.
if not code:
return None
stripped = code.strip().replace(".", "")
return stripped if stripped.isdigit() and len(stripped) in (6, 8, 10) else None
def _is_effective(self, row: Dict[str, Any], as_of: date) -> bool:
eff, exp = row.get("effective_date"), row.get("expiry_date")
if eff and as_of < eff:
return False
if exp and as_of > exp:
return False
return True
def _audit(self, item: LineItem) -> str:
payload = f"{item.sku}|{item.description}|{item.hts_code}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def resolve(self, item: LineItem, as_of: Optional[date] = None) -> ClassificationResult:
entry_date = as_of or date.today()
audit_hash = self._audit(item)
digits = self._digits(item.hts_code)
# 1. Structurally valid code that exists in the active snapshot.
if digits is not None and digits in self._snapshot:
row = self._snapshot[digits]
if not self._is_effective(row, entry_date):
# Code exists historically but is not active on the entry date —
# never apply a superseded rate automatically (19 USC 1504).
logger.warning(
"Superseded HTS %s for SKU %s; routing to broker review.",
digits, item.sku,
)
return ClassificationResult(
item, digits, HtsStatus.BROKER_REVIEW, 0.0,
self._max_duty_rate, audit_hash,
)
rate = Decimal(str(row.get("general_rate", "0")))
logger.info("HTS resolved: %s -> SKU %s @ %s", digits, item.sku, rate)
return ClassificationResult(
item, digits, HtsStatus.VALID, 1.0, rate, audit_hash,
)
# 2. Missing/malformed/unknown code -> conservative provisional hold.
logger.warning(
"Missing or invalid HTS for SKU %s (raw=%r); provisional %s @ max rate.",
item.sku, item.hts_code, self._placeholder,
)
return ClassificationResult(
item, self._placeholder, HtsStatus.FALLBACK_HEURISTIC, 0.0,
self._max_duty_rate, audit_hash,
)
def calculate_duty(result: ClassificationResult) -> Decimal:
"""Ad valorem duty with CBP half-up rounding. Blocks on unverified holds."""
if result.status is not HtsStatus.VALID:
raise ValueError(
f"Duty blocked: {result.line_item.sku} is {result.status.value}; "
"resolve classification before computation."
)
line_value = result.line_item.quantity * result.line_item.unit_price
return (line_value * result.duty_rate).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
The critical contract is in calculate_duty: it raises on any status other than VALID. A provisional 9999999999 placeholder or a superseded code can never leak into a filed duty figure — it forces the batch to surface the hold to a broker first.
VALID; both hold states carry the conservative 0.2500 ceiling and confidence 0.0, and calculate_duty() raises on anything but VALID.Verification steps
Run these checks against a representative batch before trusting the resolver in production:
- Null / empty / malformed parity. Feed three items with
hts_code=None,"", and"85.17"(too short). All three must returnFALLBACK_HEURISTICwithconfidence_score == 0.0— confirm your upstream mapper does not convert missing values into the string"None", which would passisdigit()asFalsebut still needs the same treatment. - Snapshot version pin. Query the active table (
SELECT max(effective_date) FROM hts_schedule_snapshot;). The date passed asas_ofmust fall inside that snapshot’s window; a stale snapshot causes silent rate drift, not an error. - Superseded-code rejection. Inject a line item whose code was valid last year but has an
expiry_datebeforeas_of. Assert the result isBROKER_REVIEW, notVALID— this is the single most common regression when the snapshot loses its validity columns. - Duty arithmetic checksum. For each
VALIDresult, independently computequantity * unit_price * duty_rateand confirmcalculate_dutymatches to the cent underROUND_HALF_UP. A mismatch means afloatslipped intounit_priceorgeneral_rate. - Block-on-hold gate. Assert
calculate_dutyraisesValueErrorfor bothFALLBACK_HEURISTICandBROKER_REVIEW. No provisional line item may reach ACE filing. - Audit chain integrity. Match
audit_hashacross the ingestion log, the resolver output, and the broker manifest for the same SKU. Any divergence indicates payload mutation or a race against an in-flight tariff swap. - Quarantine record count. Compare the count of non-
VALIDresults against rows landed in the broker-review queue. They must be equal; a shortfall means an exception path is swallowing records instead of routing them.
Edge cases & gotchas
- Character-encoding corruption in descriptions. Latin-1 or Windows-1252 feeds re-decoded as UTF-8 turn accented product names into mojibake, which poisons any keyword heuristic and mis-hashes the audit payload. Normalize to UTF-8 (
errors="strict", not"replace") at ingestion and quarantine on decode failure rather than lossily patching bytes. "None"as a literal string. Some upstream serializers stringify PythonNonebefore it reaches the pipeline.if not codecatchesNoneand"", but"None"is truthy — it survives to_digits, failsisdigit(), and lands in fallback correctly, but your logs will showraw='None'. Treat that as a data-quality bug in the source mapper, not a resolver success.- Formatted vs. digit-only codes. Feeds mix
8517.62.0000and8517620000. Snapshot keys must be digit-only or lookups miss valid codes and over-route to fallback._digitsnormalizes on read; make sure the snapshot loader normalizes on write too. - Digit-length ambiguity. A 6-digit international HS code is a valid subheading but is not filable as a 10-digit US HTS.
_digitsaccepts 6/8/10 for lookup, but a 6-digit match should still route to broker review before filing — extend the effective-code branch if your snapshot mixes granularities. - Timezone-naive entry dates.
date.today()uses the host clock. For a border-crossing entry near midnight UTC, a naive local date can select the wrong tariff window. Pass an explicit, timezone-derivedas_offrom the entry event, never the server default. - Batch abort on one bad row. If you call
resolveinside a list comprehension that also callscalculate_dutyeagerly, one held SKU raises and kills the whole batch. Resolve the full batch first, then compute duty only over theVALIDsubset — hold the rest for the broker queue described in Fallback Routing for Unmapped Codes.