If you've ever tried to compute US import duties programmatically, you know the problem isn't the math — it's the data. The rates live in the USITC Harmonized Tariff Schedule (a website that fights scrapers), the Section 301 lists are PDFs and Federal Register notices, and the fee tables change every fiscal year. Every "tariff calculator" tutorial online ends with MFN_RATE = 0.165 # update manually.
We run a free tariff calculator and got tired of that, so we publish everything the engine knows as versioned, MIT-licensed open data on GitHub — updated monthly, every rate line carrying its legal basis and source link. In this post I'll walk through what's in the three repos, then build five real things with pandas: analyze the 60-country tariff tiers, slice the China Section 301 lists, batch-price a SKU catalog, detect Section 232 hits, and preview the machine-readable changelog.
All numbers below come from the current snapshot v0.3.4 (as of 2026-10-07), and every code snippet was run against it.
The repos
| Repo | What's inside | Format |
|---|---|---|
| invoicetariff/invoicetariff | The full stacked ruleset ruleset-seed.json (MFN + 301 + 232 + 338 + IEEPA + fees), changelog.json, destinations.json, refund-channels.json, and three CSV extracts |
JSON + CSV |
| invoicetariff/us-tariff-rates-2026 | The same ruleset-seed.json as a standalone single-file dataset |
JSON |
| invoicetariff/hs-codes-database |
mfn-full.json: all 5,689 US HTS 8-digit codes with the official MFN general rate |
JSON |
git clone https://github.com/invoicetariff/invoicetariff
git clone https://github.com/invoicetariff/hs-codes-database
git clone https://github.com/invoicetariff/us-tariff-rates-2026 # optional, same JSON as the main repo
pip install pandas
What makes this more than a data dump: every Section 301/232/338/IEEPA/fee line carries a legalBasis string and a source object (title + URL — usually the Federal Register notice). When your spreadsheet says "12.5%", you can answer "which document, which paragraph". And the whole thing is bilingual — every name field has a nameEn/Chinese pair, because a lot of cross-border sourcing happens in Chinese.
Loading it without the footguns
Two things will bite you in the first five minutes, so let's get them out of the way:
import json
import pandas as pd
ruleset = json.load(open("invoicetariff/data/ruleset-seed.json"))
hts = pd.read_csv("invoicetariff/data/us-tariff-ruleset-hts-codes.csv",
encoding="utf-8-sig", # the CSVs carry a BOM
dtype={"hts8": str}) # see below
econ = pd.read_csv("invoicetariff/data/us-tariff-ruleset-economies.csv", encoding="utf-8-sig")
s232 = pd.read_csv("invoicetariff/data/us-tariff-ruleset-section232.csv", encoding="utf-8-sig")
full = json.load(open("hs-codes-database/mfn-full.json"))
hts["hts8"] = hts["hts8"].str.zfill(8) # defensive; cheap insurance
-
encoding="utf-8-sig"— the CSV extracts are UTF-8 with BOM. With plainutf-8, your first column is silently named\ufeffhts8. -
dtype={"hts8": str}— without it, pandas reads the 8-digit codes asint64. That's fine until you join against the JSON datasets, where codes are strings, and the merge blows up with a type error. Thezfill(8)matters if you bring your own dataset that includes chapter-01 codes (01012100, live horses) — those have real leading zeros. -
MFN rates are text, not numbers. The
generalcolumn is the official USITC text:Free,16.5%, but also genuinely non-ad-valorem rates like2.1¢/kg + 12%. Parse defensively:
def ad_valorem(text):
t = str(text).strip()
if t.lower() == "free": return 0.0
if t.endswith("%"):
try: return float(t[:-1])
except ValueError: return float("nan")
return float("nan") # specific duties (¢/kg, ¢/unit) need quantity data
hts["mfn_pct"] = hts["mfn_general"].map(ad_valorem)
# in the curated 484-code table: 189 Free, 245 ad-valorem, 50 with a specific-duty component
That last number is worth internalizing: one in ten codes can't be priced from value alone. If a tool promises you "the duty" from an HTS code and a value without asking for unit weight, it's skipping those.
1. The 60-country Global 301 program is only two rates
The newest layer — a global Section 301 program effective 2026-07-24 — assigns every economy a tier rate. Load economies.csv and the drama collapses into two numbers:
econ["global301_rate_pct"].value_counts().sort_index()
# 10.0 19 ← 19 countries at 10%
# 12.5 41 ← 41 countries at 12.5% (China sits here, as do the EU, Japan, Korea…)
Two tiers, 19 vs 41 countries. The practical takeaway for anyone with a multi-country supply chain: the rate barely discriminates, but the combined cap (mfn301_combined_cap_pct) and per-country add-on columns are where countries diverge. Check your origin countries against this table before assuming "country of origin doesn't matter anymore" — for the 301 layer it mostly doesn't, which is exactly why the next dataset matters.
2. Slicing the China Section 301 lists
ruleset-seed.json → .china301[] has 25 lines in v0.3.4 — Lists 2/3/4A plus the 2026 four-year-review additions. A three-line pivot tells you where the pain is:
c301 = pd.DataFrame(ruleset["china301"])
print(c301.groupby("rate")["hts8"].count())
# 7.5 12 ← List 4A lines
# 25.0 12 ← List 3 + the 2026 review additions (e.g. lithium batteries 85076000)
# 50.0 1 ← solar cells 85414200, doubled in the review
Half the lines doubled to 25% during the statutory four-year review, and solar cells went to 50%. If your landed-cost model still says "China 301 = 7.5%", it's describing 2019.
3. Batch-pricing a SKU catalog (the one most people actually want)
Here's the workflow that earns its keep: you have a product catalog with HTS codes, and you want an all-in duty+fee estimate per SKU. Join the curated CSV with the fee table and the country tier:
fy27 = next(f for f in ruleset["fees"] if f["fiscalYear"] == 2027)
# MPF: 0.3464% of value, min $34.58, max $670.86 (FY2027, in effect 2026-10-01)
# HMF: 0.125%, ocean only
skus = pd.DataFrame([
{"sku": "TEE-BLK-M", "hts8": "61091000", "units": 5000, "unit_value": 4.00, "mode": "ocean"},
{"sku": "LAP-14", "hts8": "84713001", "units": 200, "unit_value": 250.0, "mode": "air"},
{"sku": "BATT-PACK", "hts8": "85076000", "units": 1000, "unit_value": 18.0, "mode": "air"},
])
skus["value"] = skus["units"] * skus["unit_value"]
df = skus.merge(hts[["hts8", "mfn_pct", "china301_rate_pct"]], on="hts8", how="left")
cn_tier = float(econ.loc[econ["country_code"] == "CN", "global301_rate_pct"].iloc[0]) # 12.5
for col in ["mfn_pct", "china301_rate_pct"]:
df[col + "_usd"] = df["value"] * df[col].fillna(0) / 100
df["global301_usd"] = df["value"] * cn_tier / 100
df["mpf_usd"] = df["value"].map(lambda v: min(max(v * fy27["mpfRate"]/100, fy27["mpfMin"]), fy27["mpfMax"]))
df["hmf_usd"] = df["value"] * fy27["hmfRate"]/100 * (df["mode"] == "ocean")
df["total_usd"] = df[["mfn_pct_usd", "china301_rate_pct_usd", "global301_usd", "mpf_usd", "hmf_usd"]].sum(axis=1)
df["eff_pct"] = (df["total_usd"] / df["value"] * 100).round(2)
Output (China origin, FY2027 fees):
sku hts8 value mfn 301-list 301-global mpf hmf total eff%
TEE-BLK-M 61091000 20000 3300 1500 2500 69.28 25.0 7394 36.97%
LAP-14 84713001 50000 0 0 6250 173.20 0.0 6423 12.85%
BATT-PACK 85076000 18000 612 4500 2250 62.35 0.0 7424 41.25%
Read the middle row twice. Laptops have a Free MFN rate and sit on no China list — yet they cost 12.85% all-in, purely because of the global tier and fees. And lithium-ion battery packs — 2026's four-year-review poster child — stack 3.4% MFN + 25% review list + 12.5% global tier into 41%. If you source either, your pricing model needs the stack, not a single rate.
(Note what this toy model skips: Section 232, exclusions, informal-entry flat fees under $2,500, and the specific-duty codes from earlier. Which brings us to…)
4. Detecting Section 232 hits with prefix matching
s232.csv has the 8 active measures — steel/aluminium/copper articles at 50%, autos at 25%, furniture at 25%, pharmaceuticals at 20% (general tier, since 2026-09-29), softwood timber at 10%, semiconductors at 25%. The hts_scope column mixes 2-digit chapters (73) with code-prefix lists (847150; 847180; 847330), so normalize to prefixes and match:
prefixes = {p.strip() for scope in s232["hts_scope"] for p in str(scope).split(";")}
hit = hts[hts["hts8"].map(lambda c: any(c.startswith(p) for p in prefixes))]
print(len(hit)) # 89 of the 484 curated codes are 232-exposed
In the curated set that's 89 codes — 18% — and chapter 30 alone (pharmaceuticals) is a wall of them. If your catalog touches steel, aluminium, copper, autos, timber, furniture, pharma, or semiconductors, a "China-only" tariff model is the wrong model: 232 doesn't care where the goods ship from.
5. The changelog is the dataset's killer feature
Rates you can look up. Changes are what blindside you. changelog.json is a machine-readable feed of every rule change we track, and it's what powers the live tariff change radar:
import collections
cl = json.load(open("invoicetariff/data/changelog.json"))["entries"]
print(len(cl)) # 57 entries total
print(collections.Counter(e["layer"] for e in cl))
# {'mfn': 35, 'section232': 8, 'china301': 5, 'section338': 4, 'ieepa': 2, 'fees': 2, 'global301': 1}
print(sum(1 for e in cl if e["date"] >= "2026-01-01")) # 54 entries dated in 2026 alone
Fifty-four entries in ten months. Entries carry date, layer, status (published / upcoming), and bilingual titles — upcoming items include the China exclusions expiry (2026-11-10) and the extended truce deadline (2027-01-10). Each entry has an id prefixed with its date, so a diff-able state file is all you need to turn this into a monitoring bot — which is exactly what we'll build in the follow-up post.
Data quality: read the flags before you ship
The dataset is honest about its edges, and you should be too when you build on it:
-
v0.3.x is a Phase-1 snapshot. Except the FY2027 fee table, rate lines carry
sample/unverifiedannotations — good for estimates, pricing scenarios, and demos; not yet certified for production customs declarations. Themeta.nextHardDatesfield tracks what's being verified next. -
Coverage is two-tiered by design: 484 curated codes with the full stack in
ruleset-seed.json, all 5,689 codes MFN-only inmfn-full.json. Join them for broad-but-shallow or shallow-but-deep. -
It moves. The ruleset is versioned and updated monthly; every CSV row carries
ruleset_version/ruleset_asof, so a stale analysis announces itself. Pin the version you ran against, and re-run when it bumps.
If you'd rather eyeball the stack than code it, the calculator runs the same ruleset in the browser and links every result line back to the JSON entry; the HTS search covers all 5,689 codes. The CSV extracts are also downloadable with a citation guide at tariff-data (CC BY 4.0; the JSON is MIT).
What would you build?
A landed-cost Shopify app, a procurement dashboard, an LLM tool-calling endpoint for "what does this part cost to import" — the data's the boring part now, which is the point. If you build something on it, open an issue on the repo or drop it in the comments; I'm curious what directions this goes.
All figures in this post come from InvoiceTariff ruleset v0.3.4 (as of 2026-10-07) and the FY2027 fee table — estimates for planning, not legal advice; your customs broker signs the entry, not your notebook.
If you want the monitoring side — getting told when the stack changes instead of re-checking — the follow-up walks through a 60-line change radar on top of changelog.json.
Top comments (0)