Point-in-time HTS resolution with recursive CTEs
Resolving a 10-digit HTS code to its full classification lineage — chapter, then heading, then subheading, then statistical suffix — and the duty rate that was legally in force on the entry date is a graph walk over an effective-dated table, and the cleanest way to do it in PostgreSQL is a single recursive common table expression. This page builds that CTE against a bitemporal schedule table: it climbs the parent chain from the filed code up to its two-digit chapter, selects the one rate row whose validity window contains the as-of date at each level, guards the recursion against cycles and runaway depth, and hands the whole chain back to Python through an asyncpg fetch. The result is one round trip that returns an ordered, audit-ready ancestry with the correct historical rate — never today’s rate applied to a back-dated entry. It reads the same bitemporal table the loaders in asyncpg vs psycopg3 for HTS schedule bulk upserts populate, and it sits under the same design notes in HTS Schedule Database Design.
Prerequisites
This solution assumes a specific table shape and toolchain. Confirm each before applying it:
- PostgreSQL 12+ for
WITH RECURSIVEplus theSEARCHandCYCLEclauses used as depth and loop guards. The query is standard SQL and does not depend on extensions. - A bitemporal schedule table where every node stores its own digit-only
hts_code, aparent_code(the code with the next-shorter significant length, orNULLat the chapter root), and a validity window (effective_date,expiry_date) plusgeneral_rate. This is the contract produced by Tariff Update Ingestion Pipelines; if your table lacksparent_code, derive it once by prefix during load rather than at query time. - Digit-only codes end to end. The walk joins
parent_code = hts_code, so a mixed8517.62.0000/8517620000corpus breaks the chain silently. Normalize dots away on write, matching the resolver in Handling missing HTS codes in ETL pipelines. - Python 3.10+ with
asyncpg>=0.29. The fetch usesDecimalforgeneral_rate— neverfloat, because ISO 4217 minor-unit rounding is not representable in binary floating point — and returns a frozen dataclass per level. - Structured logging configured (stdlib
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat) so every resolution, and every depth-guard trip, is greppable for CBP audit.
Implementation
The recursive CTE has two members. The anchor selects the filed code’s row that is effective on the as-of date. The recursive member joins each node to its parent_code, again constrained to the row effective on that same date, incrementing a depth counter. The CYCLE clause aborts on any repeated code, and an explicit depth < 6 predicate caps the walk at the four significant levels plus slack. asyncpg runs it in one round trip.
import asyncio
import logging
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
import asyncpg
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("hts_pit_resolver")
# One round trip: climb parent_code from the filed code to its chapter root,
# picking the row whose [effective_date, expiry_date) window contains $2 at
# every level. CYCLE guards against a malformed parent loop; depth < 6 caps the
# walk at chapter->heading->subheading->stat-suffix plus one level of slack.
PIT_LINEAGE_SQL = """
WITH RECURSIVE lineage AS (
-- anchor: the filed code, as of the entry date
SELECT s.hts_code,
s.parent_code,
s.description,
s.general_rate,
s.effective_date,
s.expiry_date,
1 AS depth
FROM hts_schedule s
WHERE s.hts_code = $1
AND s.effective_date <= $2
AND (s.expiry_date IS NULL OR s.expiry_date > $2)
UNION ALL
-- recursive member: step up to the parent, same as-of window
SELECT p.hts_code,
p.parent_code,
p.description,
p.general_rate,
p.effective_date,
p.expiry_date,
l.depth + 1
FROM hts_schedule p
JOIN lineage l ON p.hts_code = l.parent_code
WHERE l.depth < 6
AND p.effective_date <= $2
AND (p.expiry_date IS NULL OR p.expiry_date > $2)
)
CYCLE hts_code SET is_cycle USING path
SELECT hts_code, description, general_rate, effective_date, expiry_date, depth
FROM lineage
WHERE NOT is_cycle
ORDER BY depth DESC; -- chapter first, filed code last
"""
@dataclass(frozen=True)
class LineageNode:
hts_code: str
level: str # "chapter" | "heading" | "subheading" | "statistical"
description: str
general_rate: Decimal # Decimal, never float
effective_date: date
expiry_date: date | None
_LEVEL_BY_LEN = {2: "chapter", 4: "heading", 6: "subheading",
8: "statistical", 10: "statistical"}
async def resolve_point_in_time(
pool: asyncpg.Pool, hts_code: str, as_of: date
) -> list[LineageNode]:
"""Return the chapter->...->filed-code chain effective on `as_of`."""
code = hts_code.strip().replace(".", "")
if not (code.isdigit() and len(code) in (6, 8, 10)):
raise ValueError(f"Non-filable HTS code: {hts_code!r}")
rows = await pool.fetch(PIT_LINEAGE_SQL, code, as_of)
if not rows:
# Either the code does not exist or nothing was effective on as_of.
logger.warning("No lineage for %s as of %s", code, as_of)
raise LookupError(f"{code} has no effective row on {as_of.isoformat()}")
chain = [
LineageNode(
hts_code=r["hts_code"],
level=_LEVEL_BY_LEN.get(len(r["hts_code"]), "unknown"),
description=r["description"],
general_rate=r["general_rate"], # asyncpg maps numeric -> Decimal
effective_date=r["effective_date"],
expiry_date=r["expiry_date"],
)
for r in rows
]
# A complete filable chain must terminate at a 2-digit chapter root.
if chain[0].level != "chapter":
logger.warning(
"Broken parent chain for %s: top node %s is %s, not a chapter.",
code, chain[0].hts_code, chain[0].level,
)
raise LookupError(f"Incomplete lineage for {code} as of {as_of}")
leaf = chain[-1]
logger.info(
"Resolved %s as of %s -> rate %s (chain depth %d)",
code, as_of, leaf.general_rate, len(chain),
)
return chain
async def main() -> None:
pool = await asyncpg.create_pool("postgresql://localhost/customs")
try:
chain = await resolve_point_in_time(pool, "8517620000", date(2023, 6, 1))
for node in chain:
print(f"{node.level:>11} {node.hts_code:<10} {node.general_rate}")
finally:
await pool.close()
if __name__ == "__main__":
asyncio.run(main())
The load-bearing predicate appears three times and must never be dropped: effective_date <= $2 AND (expiry_date IS NULL OR expiry_date > $2). Applying it in the anchor, the recursive member, and (implicitly) in how the parent row is chosen is what makes this point-in-time rather than point-in-now. The half-open window [effective_date, expiry_date) guarantees exactly one row is selected per level even on a boundary day, so the walk is deterministic. That determinism is why the same batch loaded idempotently by the bulk upsert benchmarks always resolves to the same historical rate.
parent_code to the chapter root under one shared as-of window. A depth < 6 predicate and the CYCLE clause bound the walk, and ORDER BY depth DESC returns the chain chapter-first.Verification steps
Run these checks against a representative schedule before trusting the resolver:
- Chain completeness. For a known 10-digit code, assert the returned chain has one node per significant level and terminates at a 2-digit chapter whose
parent_codeisNULL. A short chain means a missingparent_codelink; the code raisesLookupErrorby design rather than returning a partial lineage. - As-of correctness across a rate change. Pick a code whose
general_ratechanged on a known date. Resolve it for the day before and the day after the change and assert the two calls return different rates. Same-rate results mean the as-of predicate was dropped from the recursive member, applying today’s rate to a back-dated entry. - Boundary-day determinism. Resolve exactly on an
effective_date. Because the window is half-open[effective_date, expiry_date), exactly one row must match per level — assert the fetch returns no duplicatehts_codeat any depth. Two rows for one level means overlapping validity windows in the source data. - Depth-guard trip. Inject a deliberately malformed row whose
parent_codepoints back to a descendant. Confirm theCYCLEclause drops the cyclic rows (NOT is_cycle) and thedepth < 6cap prevents a runaway plan; the query must return, not hang. - Non-existent as-of. Resolve a code for a date before its earliest
effective_date. AssertLookupErroris raised, not an empty success — an entry can never be filed against a code that was not yet in force. - Decimal fidelity. Assert every
general_ratein the chain is aDecimaland round-trips exactly (for exampleDecimal("0.0000")for a free rate), confirming asyncpg’snumericadapter was not overridden tofloat. - Single round trip. Enable statement logging and confirm one
SELECTserved the whole chain. A per-level loop in Python defeats the point of the recursive CTE and multiplies latency by the tree depth.
Edge cases & gotchas
- Point-in-now leakage. The most damaging bug is a missing as-of predicate on the recursive member: the anchor is historical but the parents resolve to their current rows, blending eras in one chain. Apply the identical window at every level, and test it with step 2 above.
- Overlapping validity windows. If the source ever emits two rows for the same code with overlapping
[effective_date, expiry_date)ranges, the CTE returns both and the leaf rate becomes ambiguous. Enforce non-overlap as an exclusion constraint at load time; do not paper over it withLIMIT 1, which hides the data defect. parent_codederived at query time. Deriving the parent by string prefix inside the recursive member (substring(hts_code, 1, length-2)) works but forbids the index onparent_codeand slows every walk. Materializeparent_codeas a stored column during ingestion instead.- Six-digit international codes. A 6-digit WCO subheading is a valid node but is not filable as a US entry. The resolver accepts 6/8/10 for lookup, but a chain that tops out or bottoms out at six digits should be flagged before it reaches entry assembly — route it the same way ambiguous codes are handled in Fallback Routing for Unmapped Codes.
- Timezone-naive as-of dates.
as_ofselects the tariff window; a naive local date near a UTC boundary can pick the wrong day and therefore the wrong rate. Deriveas_offrom the timezone-aware entry event, never fromdate.today()on the server. CYCLErequires PostgreSQL 14+ for the standard syntax. On 12–13 you must emulate it with an arraypathcolumn and aNOT (p.hts_code = ANY(l.path))guard. Keep the depth cap regardless; it is your backstop if the cycle guard is ever removed during a refactor.