DEV Community

anders lee
anders lee

Posted on

US duty rates are not numbers: parsing 747 rate formats from the tariff schedule in Python

The duty rate column of the US tariff schedule looks like the easy part. It reads like a percentage: 6.5%, 3.7%, Free. Try float(rate.rstrip("%")) on it, though, and it fails on the very first declarable line, and special-casing Free only gets you as far as cattle (1¢/kg, still in chapter 1).

The Harmonized Tariff Schedule (USITC, 2026 Revision 22) has 19,926 declarable lines in chapters 1 to 97. A declarable line is one with nothing more detailed under it, so it is what actually goes on a customs entry. Between them, those lines use 747 different rate strings. Here are a few, copied as published:

6.5%
4.4¢/kg
$1.104/kg + 14.9%
1¢ each + 2.8%
5.7¢/kg on drained weight +8%
44¢ each + 6% on the case + 14% on the strap, band or bracelet + 5.3% on the battery
The rate applicable to each garment in the ensemble if separately entered
Enter fullscreen mode Exit fullscreen mode

That is one column. If you are building a landed cost calculator, or just want to sort products by duty, this is the column you have to deal with.

Four kinds of rate, and everything else

Customs has names for the normal cases:

  • Ad valorem: a percentage of the customs value.
  • Specific: an amount per unit, such as cents per kilogram.
  • Compound: both added together.
  • Free: exactly that, with a caveat at the end of this post.

The parser below handles those four and refuses everything else. The refusal is the important part. A rate that was read wrongly gives you a believable number, and nobody questions a believable number.

import re

NUM = r"\d+(?:\.\d+)?"
PCT = re.compile(rf"({NUM})%")
UNIT = r"kg|liter|each|pr\.|doz\.|bbl|gross|t|m2|m3|head|article"
SPECIFIC = re.compile(rf"(\$?)({NUM})(¢?)\s*/?\s*({UNIT})")


def parse_rate(text):
    """Split a published rate into terms; None means a person has to read it."""
    text = text.strip()
    if text == "Free":
        return []
    terms = []
    for part in re.split(r"\s*\+\s*", text):
        if m := PCT.fullmatch(part):
            terms.append(("pct", float(m[1]) / 100, None))
        elif m := SPECIFIC.fullmatch(part):
            usd = float(m[2]) / (100 if m[3] else 1)
            terms.append(("per", usd, m[4]))
        else:
            return None
    return terms


def kind(text):
    terms = parse_rate(text)
    if terms is None:
        return "needs a human"
    if not terms:
        return "free"
    kinds = {t[0] for t in terms}
    if kinds == {"pct"}:
        return "ad valorem"
    return "specific" if kinds == {"per"} else "compound"
Enter fullscreen mode Exit fullscreen mode

Two details matter here. fullmatch is what makes the parser refuse: 5.7¢/kg on drained weight starts like a specific rate, and a plain match would happily accept it. The units are a whitelist for the same reason. My first version took anything after the slash as the unit, and it counted kg on lead content as ordinary kilograms.

Running it over the schedule takes a few lines. The data is a flat CSV I built from the official USITC export, on Hugging Face under CC0:

import csv
from collections import Counter

with open("hts.csv", encoding="utf-8") as f:
    lines = [r for r in csv.DictReader(f)
             if r["declarable"] == "yes" and r["chapter"] not in ("98", "99")]

print(Counter(kind(r["general_rate"]) for r in lines).most_common())
Enter fullscreen mode Exit fullscreen mode
Kind Lines Share Distinct strings
Ad valorem 10,300 51.7% 201
Free 7,343 36.9% 1
Specific 1,335 6.7% 209
Needs a human 536 2.7% 159
Compound 412 2.1% 177

So 88.5% of lines really are a simple percentage or zero, which is why the naive version looks fine in a demo. The other 11.5% are not spread evenly. The 1,747 specific and compound lines cluster in vegetables (chapter 7: 292 lines), dairy (chapter 4: 142), fruit and nuts (chapter 8: 121), tobacco (chapter 24: 119) and meat (chapter 2: 102). If you sell food, the awkward rates are your normal case.

A rate is a function, not a value

A specific or compound rate cannot be turned into one percentage, because the answer depends on the price. Take 0402.29.50.00, a sweetened milk powder line, at $1.104/kg + 14.9%:

def duty(terms, value, quantity=None):
    """Duty in USD for a customs value and a quantity in the rate's own unit."""
    total = 0.0
    for what, amount, _unit in terms:
        total += amount * (value if what == "pct" else quantity)
    return round(total, 2)


rate = parse_rate("$1.104/kg + 14.9%")
print(duty(rate, value=2_000, quantity=1_000))    # 1402.0  -> 70.1% of the value
print(duty(rate, value=10_000, quantity=1_000))   # 2594.0  -> 25.9% of the value
Enter fullscreen mode Exit fullscreen mode

It is the same line and the same 1,000 kg. The cheap shipment pays 70.1% of its value and the expensive one 25.9%. So a duty_percent field in your schema is wrong by design for these lines: store the terms, and calculate when you know value and quantity.

It also means your data model needs a quantity in the rate's unit, which is not always the one you have. The units column uses different spellings from the rates (No. where the rate says each, prs. for pr.), it has both m2 and m², and 92 lines are charged per kilogram but reported in doz., kg, so two quantities go on the entry.

The 536 lines that need a person

It is tempting to treat the leftovers as parsing failures that a better regex would fix. Most of them are not. They are real legal logic written as prose:

What the text does Lines Example
Points at another rate 269 The rate applicable to each garment in the ensemble if separately entered
Uses a base other than the shipment 135 ¢/kg on lead content, on drained weight, on the value of the rifle
Splits a watch into components 96 44¢ each + 6% on the case + …
Slides with a measurement 16 … less 0.020668¢/kg for each degree under 100 degrees …
Other units and oddities 20 ¢/pf.liter, $1.34/1000, 0.3¢/line/ gross + 4.6%

Each of these needs a fact the column does not hold: the rate of each garment in a set, the lead content of the ore, the separate values of a watch case, strap and battery. Teaching a regex to read them would only produce numbers that look right. Returning None and showing the raw text is the honest output.

The text is not perfectly clean either, so normalize before you compare strings. Some rates have no space around the plus sign (40¢/kg +10.4%, … and case+ 5.3% on the battery), proof liters appear as both pf.liter and pf. liter, and 11 lines read in the in the ensemble. That typo is in the published schedule, so an exact string match on the garment rule misses them.

Things this column will not tell you

  • Free does not mean zero duty. This is the Column 1 General rate only. Chapter 99 adds duties by country of origin (Section 301, Section 232 and others) on top, and antidumping duties are separate again. Those are not in this column.
  • The right rate depends on the right code. Everything above assumes the line is correct, and classification comes down to facts about the goods, not this table. That part is what I write about at hs-code.net, with the CBP rulings behind each answer.
  • The numbers are for one revision. USITC revises the schedule many times a year, so recalculate instead of copying a table like the ones above.

If you parse this column yourself, start by counting the shapes of the strings before you write any logic. Replacing every number with N takes the 747 strings down to 84 shapes, a list you can read in a few minutes.

Top comments (0)

⌂ 换网址