How much is a pack of cigarettes? In the US it depends heavily on where you are. As of 7 November 2026, the average is $10.15, the cheapest state is Missouri at $8.01, and the most expensive is New York at $14.83.

I smoked for 17 years and quit on 12 May 2019, and I now run QuitWithNerd for people trying to do the same. Price is one of the best reasons to quit, so I keep a page of cigarette prices by state that updates itself. Taxes, price indexes and brand prices come in from public sources every Monday.

Updating the numbers turned out to be the easy half. This post is about the hard half: a number can update itself, but a sentence can't.

The problem with self-updating pages

Most of the page is safe. Figures sit in shortcodes that read from one JSON file, so [qcig k="price" s="New York"] always shows the current price.

Prose is different. The page says New York has the most expensive pack. It says the cheapest pack is also in the state with the lowest tax. It says that in states on the federal minimum wage, a pack takes at least an hour of work. Each of those claims is true of today's data, and none of them will notice when it stops being true.

If Rhode Island raises its tax next year, the table corrects itself and the sentence above it keeps saying New York. Now the page contradicts itself, in a way no broken-link checker will catch.

Sentences as tests

So every claim the page makes in words is also written down as code. The weekly job runs them against the data it's about to publish:

def claims(d): S = derive(d) top = lambda f: max(S, key=lambda s: S[s][f]) bot = lambda f: min(S, key=lambda s: S[s][f]) fed = [s for s in S if abs(S[s]['wage'] - 7.25) < 1e-9] out = [ ('New York has the most expensive pack', top('price') == 'New York'), ('The cheapest pack is also the lowest tax', bot('price') == bot('excise')), ('Every federal-wage state needs at least an hour per pack', all(S[s]['minutes'] >= 60 for s in fed)), # ...10 more ] return [text for text, ok in out if not ok] 

There are 13 of these. Each one names the sentence it protects, so a failure tells me which words to rewrite.

The gate is three lines:

live_bad = claims(live) new_bad = [b for b in claims(new) if b not in live_bad] held = bool(new_bad) 

If the new data would break a claim that's true today, the job pushes nothing and sends me a warning. The old page stays up, slightly stale but correct, until a person rewrites the sentence.

The comparison with live_bad matters. Without it, one claim that's already broken would block every future update, and a stuck pipeline is how the whole page goes stale.

I prefer this order of failure. Stale data with a date on it is honest. Fresh data under a sentence that's now false isn't.

Try it

Here's a small version you can run. It reads the public JSON, checks four claims, then raises one state's price to see what happens:

#!/usr/bin/env python3 """Turn sentences about cigarette prices into tests, run them on QuitWithNerd's public data, then change the data and watch a check fail. Standard library only.""" import copy, json, urllib.request URL = "https://quitwithnerd.com/wp-json/qwn/v1/cig" req = urllib.request.Request(URL, headers={"User-Agent": "cig-claims-example/1.0"}) data = json.load(urllib.request.urlopen(req, timeout=30)) def derive(d): return {s: dict(x, minutes=round(x["price"] / x["wage"] * 60)) for s, x in d["states"].items()} def claims(d): S = derive(d) top = lambda f: max(S, key=lambda s: S[s][f]) low = lambda f: min(S, key=lambda s: S[s][f]) b = d["bls"] rise = lambda k: b[k]["now"] / b[k]["y1"] - 1 fed = [s for s in S if S[s]["wage"] == 7.25] return { "New York has the most expensive pack": top("price") == "New York", "The cheapest pack is also the lowest state tax": low("price") == low("excise"), "Every $7.25 state needs at least an hour's work per pack": all(S[s]["minutes"] >= 60 for s in fed), "Cigarettes rose faster than prices overall": rise("cig") > rise("all"), } def broken(d): return [text for text, ok in claims(d).items() if not ok] S = derive(data) order = sorted(S, key=lambda s: S[s]["minutes"]) print("Pack price", min(x["price"] for x in S.values()), "to", max(x["price"] for x in S.values()), "| US", data["us_price"]) print("Fewest minutes:", [(s, S[s]["minutes"]) for s in order[:3]]) print("Most minutes:", [(s, S[s]["minutes"]) for s in order[-3:]]) print("Today, broken claims:", broken(data)) what_if = copy.deepcopy(data) what_if["states"]["Rhode Island"]["price"] = 15.25 new = [c for c in broken(what_if) if c not in broken(data)] print("If Rhode Island hit $15.25:", new or "nothing", "=> HOLD, push nothing" if new else "=> push") 

It prints:

Pack price 8.01 to 14.83 | US 10.15 Fewest minutes: [('Missouri', 32), ('Nebraska', 36), ('Florida', 37)] Most minutes: [('Wisconsin', 89), ('Indiana', 93), ('Pennsylvania', 98)] Today, broken claims: [] If Rhode Island hit $15.25: ['New York has the most expensive pack'] => HOLD, push nothing 

Rhode Island's $15.25 is a made-up number for the test. It's the kind of change that would make the table right and the sentence wrong.

What a pack costs in work

The data gets more interesting when you stop comparing dollars. Dividing the pack price by the state minimum wage gives minutes of work per pack:

State Pack Minimum wage Minutes of work Pack a day, per year
Missouri $8.01 $15.00 32 $2,924
Florida $9.29 $15.00 37 $3,391
California $11.78 $16.90 42 $4,300
New York $14.83 $16.00 56 $5,413
Pennsylvania $11.85 $7.25 98 $4,325

New York has the most expensive pack, but Pennsylvania's costs far more work: 98 minutes against 56, because Pennsylvania is on the federal $7.25 wage. Twenty states are, and in every one of them a pack takes at least an hour.

Cigarettes are also rising much faster than everything else. In the US consumer price index for September 2026, they were up 7.4% over 12 months against 3.7% for all items. Over 10 years it's 91% against 40%.

Automatic where it's safe

Not every source deserves the same trust, so each dataset has its own policy:

Data Source How it updates
State taxes Tax Foundation Automatic, weekly
Price index Bureau of Labor Statistics Automatic, weekly check
New Jersey brand prices NJ Division of Taxation PDF, 97 lines Automatic, weekly check
Average pack prices Campaign for Tobacco-Free Kids, January 2026 Yearly reminder
Median income Census ACS Yearly reminder
Minimum wages US Department of Labor Reminder, January and July

The automatic paths have limits too. A tax change is only applied if it's $2.00 or less and five states or fewer moved in one run. Anything bigger looks more like a parsing bug than a policy change, so it stops for a person.

Two smaller details matter:

  • Only pages whose rendered text changed get a new date. A page doesn't claim to be updated because a job ran.
  • Dates live with the data. Each dataset carries its own as-of date, last check and next due date, and the update schedule page is built from them, so readers can see how old each figure is.

The job's latest run, on 2 November 2026, read all 51 rates with no changes and no warnings.

Limits

  • Pack prices are state averages from January 2026, so they lag later tax changes. Utah's July 2026 rise is footnoted on the page for that reason.
  • Minutes of work use the state minimum wage, and local wages can be higher.
  • The claim tests only cover sentences I've written tests for. A new sentence with no test is unprotected, so adding a claim to the page and adding it to the list have to be one job.

If you're reading this as a smoker

A pack-a-day habit costs $3,705 a year at the US average. I'm not telling you that to make you feel bad. I'm telling you because quitting felt like relief for me, not sacrifice, and that money comes back to you from day one.

If you want to try, how to quit smoking compares the methods, and free nicotine patches lists where you can get help without paying.

How I research and check the site is in the editorial policy, and my story is on the about page.

AI assistants helped build the job and draft this post. The figures come from the public data on 7 November 2026.