Parsing 225/45R17: Designing a Tolerant Pipeline for Tire Size Strings Humans Actually Type
Framing note before anything else. This is a design essay about a data-modelling problem, not a description of software anyone is running. KMJ Tire is a small Calgary business that does tire work and oil changes, and nothing in this article should be read as a report on an internal platform, a deployment, a migration, or a set of production metrics. There is no such system being described here, and every identifier, row, and number below was written to make an argument legible. What is real is the domain: tire size strings are a genuinely nasty identifier format, they arrive from humans under bad conditions, and if you have ever had to build a search box that accepts one, you already know the sinking feeling. The code is illustrative and meant to be argued with.
The Grammar That Looks Clean Until Someone Types Into It
Write 225/45R17 94V on a whiteboard and it looks like a solved problem. Width, solidus, aspect ratio, construction letter, rim diameter, space, load index, speed symbol. Six fields, one delimiter, one optional trailing pair. Any junior engineer will hand you a regex in ninety seconds and it will work on every example you can think of while you are standing at the whiteboard.
Then you accept real input, and the format turns out not to be a format at all. It is a family of at least five overlapping notations, standardized by different bodies at different times for different markets, layered on top of each other without anyone ever deprecating the old ones, and rendered into a text field by a person holding a phone in a parking lot who is reading a sidewall covered in road salt.
The interesting part is not that the input is dirty. Every input is dirty. The interesting part is that the clean input is already ambiguous. There are strings that are perfectly well-formed under two different grammars and mean two different physical objects, and no amount of trimming whitespace will help you. That is the part that decides your architecture, because it means the honest output of your parser is not a value. It is a value or a question.
Five Notations Wearing One Costume
Before any code, the shape of the problem. These are the families that show up, and they are genuinely different grammars, not variants of one.
Metric, unprefixed (Euro-metric / ETRTO). 205/55R16. The default on most passenger cars sold in Canada. Section width in millimetres, aspect ratio as a percentage of that width, construction letter, rim diameter in inches.
P-metric. P205/55R16. Same numbers, leading P, standardized under a North American passenger-car load system. Physically it mounts on the same wheel. Its load ratings are computed under different assumptions, which matters later and is the single most under-appreciated trap in the whole domain.
LT and light-truck metric. LT265/70R17. Same field layout, different load standard, different inflation table, frequently a dual load index and a load range letter tagging along.
Flotation / high-flotation. 33x12.50R15. Overall diameter in inches, section width in inches, rim diameter in inches. Completely different axes from the metric form. The x is the delimiter and the aspect ratio does not appear at all because it is implied by the first two numbers.
Legacy numeric and alphanumeric. 7.50-16, G78-15, FR78-14. Mostly on trailers, older equipment, classics, and farm gear. Still walks in the door in Calgary every gravel season on utility trailers that have not been re-shod since the nineties.
To that add commercial C-type (195/70R15C), the millimetric oddities where even the rim diameter is metric, temporary-spare T sizes, and trailer-specific ST sizes. Six or seven grammars, depending on how you count, all landing in the same text box.
Field by Field Through the Metric Form
Take LT265/70R17 121/118S and walk it, because most of the domain's vocabulary lives in this one string.
LT is a service-type prefix. It changes which load table applies. 265 is the nominal section width in millimetres at a specified rim width, which is not the tread width and is not the widest point when mounted on a wider wheel than the reference. 70 is the aspect ratio: sidewall height is 70% of 265 mm. R says radial construction; D or - says bias-ply, and B says belted bias. 17 is the rim diameter, and it is in inches, sitting immediately after a millimetre measurement and a percentage, in the same token stream, with no unit marker anywhere. That mixed-unit design is not a mistake you can fix; it is the standard, and your model has to carry units explicitly or you will eventually add 17 to 265.
121/118 is a dual load index: 121 in single-wheel application, 118 in dual-wheel application, which is why the pair exists on light-truck sizes and not on passenger sizes. S is the speed symbol, 180 km/h. Together 121/118S is the service description, and it is optional in the sense that people omit it constantly, but not optional in the sense that the tire has one stamped on it. A size without a service description is an under-specified request, not an invalid one, and the difference matters when you decide what to do with it. If you want the consumer-facing version of this vocabulary, the sidewall markings explainer covers the same territory without the parsing anxiety.
The thing to notice: five of those seven fields are optional in practice. Your grammar has to make optionality first-class, not bolt it on with ? characters until the pattern is unreadable.
Load Range, Load Index, and Ply Rating Are Three Different Axes
People conflate these constantly, including people who work with tires daily, and if you model them as one column you will be wrong in a way that is expensive.
Load index is a numeric code from a lookup table that maps to a maximum load per tire at a reference inflation pressure. 94 means 670 kg. 121 means 1450 kg. It is a code, not a quantity, and the mapping is a table you must ship — you cannot compute it, and interpolating it is wrong. Store the code, resolve to kilograms through a table, and treat the table as reference data with a version. The load index explainer is the plain-language version of the same lookup.
Load range is a letter — B, C, D, E, F — that classifies a tire's inflation-pressure capability. It appears on light-truck and commercial sizes and typically does not appear on passenger sizes.
Ply rating is the legacy expression of the same axis as load range. "10 ply rated" and "Load Range E" are the same claim. Critically, neither means the tire contains ten plies; modern radials achieve the rating with two or three plies of much better material. If your data model has a ply_count integer populated from a marketing string, it is lying to you.
So: load index and load range are correlated but not derivable from one another, and ply rating is a synonym for load range in different clothes. Three columns, one of them nullable by family, with a documented relationship between two of them. Not one column named load.
Flotation Sizes Measure the Tire, Not the Air Column
33x12.50R15 reads as 33 inches overall diameter, 12.50 inches section width, 15 inch rim. This is a different measurement philosophy: the metric form describes the tire relative to itself (a percentage), while the flotation form describes it in absolute terms.
Two consequences fall out immediately. First, there is no aspect ratio field to parse, so a parser that requires one rejects an entire family. Second, the "33" is nominal in a way the metric fields are not. A tire labelled 33 inches frequently measures 32.1 or 32.4 inches at its actual rolling diameter with load on it. The metric form's derived diameter is a computation from stated fields; the flotation form's diameter is a marketing-rounded declaration. When you compare across families — and you will, because customers ask "what's the metric equivalent of my 33s" every week — you are comparing a computed value against a rounded one, and you must carry that asymmetry in your confidence.
Flotation sizes also often carry a trailing LT (35x12.50R20LT) rather than a leading one, which is a real gift to anyone writing a single regex. The service-type marker moves position depending on family. That alone should tell you the mega-regex is doomed.
The Legacy Forms Still Show Up on Trailers
7.50-16 is a numeric size: nominal section width 7.50 inches, hyphen indicating bias-ply construction, 16 inch rim. Its radial sibling is written 7.50R16. The aspect ratio is not stated because these sizes predate the convention; treat it as roughly 92 to 98 percent depending on the series, and be honest in your model that it is assumed rather than parsed.
FR78-14 is alphanumeric. F is a load-and-size class letter, R says radial, 78 is the aspect ratio, 14 is the rim. Note that R here sits inside the leading token rather than before the rim diameter. G78-15 is the bias version of the same idea. A parser written for the metric family will look at FR78-14 and see garbage, or worse, will see 78-14 and confidently produce a 78 mm wide tire.
These are not hypothetical museum pieces. Utility trailers, boat trailers, older farm equipment, and vintage vehicles carry them, and they arrive at a tire business in Calgary throughout gravel season on trailers whose owners have never once thought about the size until something let go on Deerfoot. Any model that treats these as out of scope has decided that a small but real slice of requests will fail silently, which is a decision, but it should be a deliberate one.
There is one more form worth naming: millimetric sizes such as 190/65R390, where the rim diameter is in millimetres, not inches. The string is structurally identical to a metric passenger size. Only the magnitude of the last field tells you it is a 390 mm rim rather than a 390 inch one, which is exactly the kind of disambiguation-by-plausibility that has to live in your validator rather than your grammar.
ZR, and the Speed Symbol That Wandered Into the Size Field
Here is the case that breaks the tidy mental model that a size describes geometry and a service description describes performance.
225/45ZR17 91W has a speed symbol in two places. The Z in ZR is a legacy high-speed indicator meaning "above 240 km/h", inherited from an era when that was the top of the table and no further precision existed. The W in the service description says 270 km/h, precisely. They are not contradictory, they are two generations of the same claim, and the modern one wins.
Now the harder variant: 225/45ZR17 with no service description at all. There is no load index and no precise speed symbol. You know the tire is rated above 240 km/h and you know nothing else about its load capacity. A parser that returns speed_symbol = 'Z' is technically fine; a parser that returns speed_symbol = 'ZR' has fused two fields; a parser that silently sets speed_symbol = 'W' because most ZR tires are W-rated has invented data and should be taken out behind the building.
Design position: model construction and speed_symbol as separate fields, allow construction = 'ZR' as a distinct value from 'R', and let speed_symbol be null. When both a ZR construction and a service-description speed symbol are present, store both and expose the service-description value as the resolved one, with the ZR retained as provenance. Do not normalize away information that the string actually contained.
XL, Reinforced, and the Suffixes That Change the Answer
225/45R17 94V and 225/45R17 91W XL are both real. XL (Extra Load) and Reinforced are the same designation from different regional traditions; they mark a tire built to carry more at a higher maximum inflation pressure than the standard-load version of the same size. Some manufacturers print RF instead. Some print EXTRA LOAD in full on the sidewall and nothing at all in the catalogue string.
The parsing consequence is that this marker is a free-floating optional token that can appear before or after the service description, in three or four spellings, and that it changes the load index of the size. Two tires with the same dimensional string and different load markers are not the same product and are not always interchangeable on the same vehicle. If a vehicle's placard specifies an extra-load tire, fitting the standard-load version of the identical dimensional size is a load-capacity downgrade, and a system that treats those two strings as equal will happily recommend it.
So XL is not cosmetic trailing text to be stripped. It is a field. Same for M+S, 3PMSF implications, C for commercial, and POR/MPT markings on specialty light-truck rubber. Strip nothing until you have decided what it means. Whether a given fitment should be all-season, all-weather, or a dedicated winter set is a separate question from the size grammar, and worth reading about separately if you are curious how all-weather tires differ from the all-season category.
Why the Single Mega-Regex Rots
The instinct is to write one pattern with enough alternation and optional groups to cover everything. It works for a while. Here is the decay curve, and it is the same in every codebase I have watched go through it.
Version one handles metric with an optional service description. Version two adds LT and P prefixes as an optional alternation at the front. Version three adds flotation, which requires a whole parallel branch because the field semantics differ. Version four adds the trailing-LT case, so the prefix alternation now has a suffix twin. Version five adds ZR, which means the construction group is no longer a single character. Version six adds XL, which can appear in two positions, so it becomes an optional group in two places, and now the same capture name cannot be used twice in one Python pattern, so it becomes xl1 and xl2 and a post-processing step that coalesces them.
By version six the pattern is four hundred characters long, has eleven optional groups, and nobody can tell you whether a given input fails because it is genuinely invalid or because group nine's optionality interacts badly with group four's. Worse, it has become impossible to answer the most useful diagnostic question in the entire pipeline: how far did we get before we failed? A regex match is a boolean. It cannot tell a user "I understood the width and the aspect ratio and then found something I didn't recognize where the rim diameter should be." That message is worth more than the parse itself, because it is the message that lets the person fix their own input.
The other failure is subtler. A mega-regex has one accept state, so it cannot report multiple successful interpretations. When a string is legitimately ambiguous — and several are — you need the parser to hand back two candidates and let a higher layer decide or ask. An alternation-based regex just returns whichever branch the engine tried first, which is an arbitrary function of how you happened to order the alternatives.
A Tokenizer Front End, Then One Grammar Per Family
The design I would actually build: a scanner that turns the raw string into a typed token list, a family classifier that inspects the token shape, and a small dedicated pattern per family that only has to handle that family's optionality. Three layers, each independently testable.
The scanner does not know what a tire is. It knows numbers, decimals, letters, and separators.
import re
from dataclasses import dataclass, field
from typing import Optional, Literal
TOKEN_RE = re.compile(r"""
(?P<decimal>\d+\.\d+)
| (?P<integer>\d+)
| (?P<alpha>[A-Za-z]+)
| (?P<slash>/)
| (?P<cross>[xX*])
| (?P<dash>-)
| (?P<plus>\+)
| (?P<space>\s+)
""", re.VERBOSE)
@dataclass(frozen=True)
class Token:
kind: str
text: str
pos: int
def scan(raw: str) -> list[Token]:
out: list[Token] = []
i = 0
while i < len(raw):
m = TOKEN_RE.match(raw, i)
if not m:
out.append(Token("junk", raw[i], i))
i += 1
continue
kind = m.lastgroup or "junk"
if kind != "space":
out.append(Token(kind, m.group(), m.start()))
i = m.end()
return out
Two things about that scanner earn their keep. It never fails — unrecognized bytes become junk tokens with a position, so downstream code can say exactly where confusion began. And it collapses whitespace without deleting structure, so 205 55 16, 205/55/16, and 205/55R16 all produce comparable token streams that differ only in separators, which is precisely the difference a classifier wants to reason about.
Cleaning Input Without Destroying Evidence
There is a strong temptation to run a normalize() pass over the raw string first: uppercase it, squash whitespace, replace unicode lookalikes, strip punctuation. Some of that is safe. Some of it quietly destroys the information you need.
Safe: Unicode normalization to NFKC, converting the fraction slash ⁄ and full-width solidus to ASCII /, converting non-breaking and zero-width spaces to ordinary spaces, uppercasing letters, and trimming. Phones and PDFs produce all of these, and none of them carry meaning here.
Not safe: stripping -, because the hyphen in 7.50-16 is the construction marker distinguishing bias from radial. Not safe: collapsing all separators to one canonical separator, because 33x12.50R15 versus 33/12.50R15 is a family signal. Not safe: removing spaces entirely, because 205 55 16 collapses to 2055516 and you have just thrown away the only boundary information in the string. Not safe: deleting trailing letters as noise, because that is where XL and C and LT live.
import unicodedata
SLASH_LOOKALIKES = {"⁄", "/", "∕", "\\"}
SPACE_LOOKALIKES = {" ", " ", " ", "", "\t"}
def pre_clean(raw: str) -> str:
text = unicodedata.normalize("NFKC", raw)
text = "".join("/" if ch in SLASH_LOOKALIKES else ch for ch in text)
text = "".join(" " if ch in SPACE_LOOKALIKES else ch for ch in text)
text = text.upper().strip()
return re.sub(r"\s{2,}", " ", text)
Notice what this function does not do. It does not remove anything it does not understand, it does not reorder, and it returns a string of roughly the same shape as the input. Everything semantic happens downstream, where there is enough context to know whether a character is noise or a field. The rule I would put in the code review checklist: a cleanup step may only replace a character with a semantically equivalent one, never delete a character whose meaning is family-dependent.
Named Groups, One Family at a Time
With a clean string and a token stream, the per-family patterns become short enough to read in one pass. Each one is anchored, each one names its fields, and each one is allowed to be strict, because a failure here is not fatal — the dispatcher just tries the next family.
SERVICE_DESC = r"""
(?:\s*(?P<load_index>\d{2,3})
(?:/(?P<load_index_dual>\d{2,3}))?
\s*(?P<speed>[A-Z]{1,2}))?"""
TRAILING = r"""
(?:\s*(?P<extra_load>XL|RF|REINFORCED))?
(?:\s*(?P<load_range>[B-F])(?![A-Z]))?
\s*$"""
TAIL = SERVICE_DESC + TRAILING
PATTERNS: dict[str, re.Pattern] = {
"metric": re.compile(r"""
^(?P<service_type>P|LT|ST|T)?
(?P<width>\d{3})
/(?P<aspect>\d{2})
(?P<construction>ZR|R|D|B|-)
(?P<rim>\d{2}(?:\.5)?)
(?P<commercial>C)?""" + TAIL, re.VERBOSE),
"flotation": re.compile(r"""
^(?P<diameter_in>\d{2}(?:\.\d)?)
\s*X\s*
(?P<width_in>\d{1,2}\.\d{2})
(?P<construction>ZR|R|D|B|-)
(?P<rim>\d{2}(?:\.5)?)
(?P<service_type>LT)?""" + TAIL, re.VERBOSE),
"numeric": re.compile(r"""
^(?P<width_in>\d\.\d{2})
(?P<construction>R|-)
(?P<rim>\d{2}(?:\.5)?)""" + TAIL, re.VERBOSE),
"alphanumeric": re.compile(r"""
^(?P<size_class>[A-N])
(?P<construction>R|-)?
(?P<aspect>\d{2})
-(?P<rim>\d{2})
\s*$""", re.VERBOSE),
}
Four patterns, none of them longer than a screen, each one comprehensible without a whiteboard. The optional service description and the trailing markers are shared fragments spliced onto each family's head, because those two parts genuinely are common across families while the dimensional heads genuinely are not — factoring the shared part out is honest, factoring the heads together would not be. Adding millimetric support means adding a fifth entry, not surgery on an existing monster. Removing alphanumeric support if you decide it is out of scope means deleting a dict key. That is the whole argument for the split, and it holds up under maintenance in a way alternation does not.
One subtlety worth flagging in the metric pattern: (?P<load_range>[B-F])(?![A-Z]) uses a negative lookahead so a bare trailing letter is only read as a load range when it is genuinely alone. Without the lookahead, the E in a trailing EXTRA LOAD would be captured as load range E, which is the kind of bug that produces a light-truck load classification on a passenger car and does not get caught by any test anyone thought to write.
Classify First, Then Match
The dispatcher's job is to pick a family cheaply from token shape before attempting expensive matching, and — critically — to be willing to return more than one result.
@dataclass
class ParsedSize:
family: str
raw: str
fields: dict
confidence: float
notes: list[str] = field(default_factory=list)
@dataclass
class ParseOutcome:
candidates: list[ParsedSize]
failure_position: Optional[int] = None
failure_hint: Optional[str] = None
def candidate_families(tokens: list[Token]) -> list[str]:
kinds = [t.kind for t in tokens]
texts = [t.text for t in tokens]
if "cross" in kinds:
return ["flotation"]
if texts and texts[0][:1].isalpha() and len(texts[0]) == 1 and "dash" in kinds:
return ["alphanumeric"]
if any(k == "decimal" for k in kinds) and "slash" not in kinds:
return ["numeric", "flotation"]
return ["metric"]
That classifier is deliberately dumb and deliberately generous. ["numeric", "flotation"] is not a hedge, it is an accurate statement that a decimal token with no slash could be either, and the downstream matcher will resolve it or report both. The cost of trying two short patterns is nothing. The cost of guessing wrong silently is a customer receiving the wrong tire.
The Canonical Form, and Why the Raw String Never Leaves
Every parse produces a canonical string. Mine would look like this: uppercase, no spaces inside the dimensional part, service type prefixed when present, construction always explicit, service description separated by a single space, markers in a fixed order.
p225/45r17 94v becomes P225/45R17 94V. 205 55 16 becomes 205/55R16. 33 x 12.50 r 15 lt becomes 33X12.50R15LT. 7.50 16 becomes 7.50R16 only if you decide the omitted construction marker defaults to radial, which is a defensible default in 2026 and should be recorded as an assumption in the parse notes rather than buried.
The canonical form is what you index, join, and compare on. It is emphatically not what you display back to the user in an error message, and it is not what you store as the sole record of what happened.
Keep the raw input. Always. Three reasons, and each one has bitten somebody.
First, canonicalization is code, and code changes. When you fix a bug in the aspect-ratio default six months from now, the only way to re-derive correct canonical forms for existing rows is to re-run the parser over the original strings. If you overwrote them, that data is gone and the only remediation is a support queue.
Second, the raw string is evidence. When a customer says "I typed my size and got the wrong result," the raw string is the entire investigation. The canonical form has already had the bug applied to it.
Third, raw inputs are the best possible test corpus. Every weird thing a real person typed is a test case you did not have to invent, and the distribution of weirdness in real traffic looks nothing like the distribution an engineer imagines while writing fixtures.
A Schema That Stores Both
create table size_input (
id bigserial primary key,
raw_text text not null,
received_at timestamptz not null default now(),
source_channel text not null,
parser_version text not null,
canonical text,
family text,
parse_status text not null
check (parse_status in ('parsed','ambiguous','unparsed')),
candidate_count int not null default 0
);
create table size_canonical (
canonical text primary key,
family text not null,
service_type text,
section_width_mm numeric(6,2),
section_width_in numeric(5,2),
aspect_ratio smallint,
aspect_assumed boolean not null default false,
construction text not null,
rim_diameter_in numeric(4,1),
rim_diameter_mm smallint,
load_index smallint,
load_index_dual smallint,
speed_symbol text,
load_range text,
extra_load boolean not null default false,
overall_dia_mm numeric(7,2),
dia_is_declared boolean not null default false
);
create index size_input_canonical_idx on size_input (canonical);
create index size_input_unparsed_idx on size_input (received_at)
where parse_status <> 'parsed';
Three design decisions in there deserve defending. parser_version on every input row means a corpus replay can tell you exactly which rows were parsed by which logic, so a bug fix produces a targeted backfill instead of a full-table rewrite. aspect_assumed and dia_is_declared are boolean provenance flags that distinguish a parsed value from an inferred one — without them, a downstream diameter comparison cannot tell the difference between a number the tire told you and a number your code guessed. And the partial index on non-parsed inputs turns "show me everything the parser choked on this week" into a fast query rather than a sequential scan, which is the difference between reviewing failures daily and never reviewing them.
Note also that width appears twice, in millimetres and inches, with the family deciding which is populated. I have seen the alternative — one width column plus a width_unit column — and it produces a codebase where every single query that touches width has to remember the unit. Two nullable columns are less elegant and dramatically harder to misuse.
Valid Is Three Different Words
The most useful conceptual move in this whole domain is refusing to let "valid" be a boolean. There are three independent tiers, and confusing them is how you end up rejecting a real tire or accepting a fictional one.
Tier one, syntactically valid. The string conforms to a grammar. 285/32R15 passes. Nothing about tier one implies the tire exists.
Tier two, physically plausible. The parsed fields fall inside ranges that real manufactured tires occupy, and the derived geometry is sane. Section widths from about 125 to 355 mm on passenger sizes. Aspect ratios in a set that is dense in multiples of five from 25 to 90, with a handful of odd values like 35 and 45 being common and something like 32 being suspicious. Rim diameters in a known set including the half-inch commercial values like 16.5 and 19.5. Load index inside the published table. Speed symbol inside the published table. Overall diameter within a band that a vehicle could physically use. 285/32R15 fails tier two: a 15 inch rim with a 285 section width and a 32 series sidewall is not a combination anyone manufactures.
Tier three, actually catalogued. Some real supplier, somewhere, lists a product in that size. This is the only tier that can answer "can I get one." It is also the only tier that changes daily and the only one you cannot compute — it is a join against reference data, and it belongs in a different part of the system than the parser.
Keeping these separate lets you write honest messages. Tier one failure: "I couldn't read that." Tier two failure: "I read that as a 285 mm wide tire on a 15 inch wheel with a very short sidewall, which isn't a size that gets built — did you mean something else?" Tier three failure: "That's a real size, but I can't find it available right now." Three completely different conversations, and a boolean is_valid collapses all three into the least useful one.
Overall Diameter Is a Cheap, Powerful Plausibility Check
Tier two gets most of its power from one piece of arithmetic. For a metric size:
MM_PER_INCH = 25.4
def overall_diameter_mm(width_mm: float, aspect_pct: float, rim_in: float) -> float:
sidewall = width_mm * (aspect_pct / 100.0)
return (rim_in * MM_PER_INCH) + (2.0 * sidewall)
def overall_diameter_in(width_mm: float, aspect_pct: float, rim_in: float) -> float:
return overall_diameter_mm(width_mm, aspect_pct, rim_in) / MM_PER_INCH
Worked, with 225/45R17, and all numbers here are illustrative arithmetic rather than measurements from any specific product:
- Sidewall height: 225 × 0.45 = 101.25 mm
- Both sidewalls: 101.25 × 2 = 202.50 mm
- Rim: 17 × 25.4 = 431.80 mm
- Overall: 202.50 + 431.80 = 634.30 mm, which is 24.97 inches
Now the same for 285/75R16, a common light-truck size:
- Sidewall: 285 × 0.75 = 213.75 mm
- Both sidewalls: 427.50 mm
- Rim: 16 × 25.4 = 406.40 mm
- Overall: 833.90 mm, or 32.83 inches
That second calculation is the reason people say a 285/75R16 is "a 33." It computes to 32.83 inches, and the flotation family rounds. Which brings us to the comparison everyone wants to make and nobody should make casually.
Cross-Reference Is Not Equality
Is 33x12.50R15 the same as 285/75R16? No. Not close, and the arithmetic says why.
Overall diameter: roughly 33 inches declared versus 32.83 inches computed. Those are within about half a percent — genuinely similar, and that similarity is exactly what makes the confusion tempting. But section width is 12.50 inches versus 285 mm, and 285 mm is 11.22 inches. That is a 1.28 inch difference in width, over ten percent. And rim diameter is 15 versus 16 inches, which means they do not mount on the same wheel at all. Two tires with nearly identical outside diameters that cannot be swapped on a vehicle without also swapping wheels.
So the relation you need is not equality. It is a typed relation with a reason and a tolerance:
create type xref_kind as enum (
'same_dimensions', -- identical geometry, different load standard
'same_fitment', -- interchangeable on the same wheel and vehicle
'similar_diameter', -- close rolling diameter, different wheel
'plus_size' -- larger wheel, shorter sidewall, matched diameter
);
create table size_cross_reference (
from_canonical text not null references size_canonical(canonical),
to_canonical text not null references size_canonical(canonical),
kind xref_kind not null,
diameter_delta_pct numeric(5,2) not null,
width_delta_mm numeric(6,2) not null,
same_rim boolean not null,
primary key (from_canonical, to_canonical, kind)
);
With that in place, 33x12.50R15 and 285/75R16 get a similar_diameter row with same_rim = false, and the user interface can say "these roll at nearly the same diameter, but they need different wheels" instead of implying a swap. Meanwhile 225/45R17 and P225/45R17 get a same_dimensions row, because the geometry genuinely is identical and only the load standard differs — a P-metric tire fitted to a light truck or SUV has its load rating derated relative to the Euro-metric equivalent, which is a real difference that a same_fitment claim would paper over.
The rule I would enforce in review: no code path may treat two canonical strings as interchangeable without consulting this table. Not string equality, not a normalization that drops the P, not a heuristic. If it is not a row, it is not a match.
Equality Has a Direction
Even within one family, equality is asymmetric in a way that surprises people.
If a customer types 225/45R17 and the catalogue has 225/45R17 94V, is that a match? Yes, in the direction the customer meant: they specified a size and the catalogue entry satisfies it. The query typed less than the data holds, which is a broader-than relation, and the correct behaviour is to match and then let the customer narrow by load index or speed symbol.
Reverse it. A customer types 225/45R17 94V and the catalogue has 225/45R17 91W XL. That is not a match. The customer specified a load index and the candidate does not carry it. Silently returning it because the dimensional part agrees is how a vehicle ends up with less load capacity than its placard requires.
So the comparison operator takes a direction and a strictness, and it is worth writing it explicitly rather than leaning on ==:
DIMENSIONAL = ("service_type", "width", "aspect", "construction", "rim")
SERVICE = ("load_index", "speed", "extra_load")
def satisfies(query: dict, candidate: dict, *, strict_service: bool = True) -> bool:
for key in DIMENSIONAL:
q, c = query.get(key), candidate.get(key)
if q is None:
continue
if key == "construction" and {q, c} == {"R", "ZR"}:
continue
if q != c:
return False
if not strict_service:
return True
for key in SERVICE:
q = query.get(key)
if q is not None and q != candidate.get(key):
return False
return True
The {"R", "ZR"} exception in there is a judgment: a query for 225/45R17 should match a 225/45ZR17 product, because the customer asking for a radial did not mean to exclude a high-speed radial. That is a domain decision, not a mechanical one, and it should be written down where a reviewer can disagree with it.
The Test Matrix Is the Real Specification
Here is the table I would build first, before any of the code above. Not because tests come first as dogma, but because in this domain the test table is the requirements document, and writing it forces every ambiguity into the open where a human has to make a decision about it.
| Raw input | Family | Canonical | Status | Note |
|---|---|---|---|---|
225/45R17 94V |
metric | 225/45R17 94V |
parsed | baseline |
p225/45r17 |
metric | P225/45R17 |
parsed | case folded, no service description |
225 45 17 |
metric | 225/45R17 |
parsed | separators inferred from magnitude |
225-45-17 |
metric | 225/45R17 |
parsed | dashes are separators here, not construction |
225/45ZR17 91W |
metric | 225/45ZR17 91W |
parsed | both speed claims retained |
225/45ZR17 |
metric | 225/45ZR17 |
parsed | speed symbol null, not inferred |
225/45R17 91W XL |
metric | 225/45R17 91W XL |
parsed | extra load is a field |
LT265/70R17 121/118S E |
metric | LT265/70R17 121/118S E |
parsed | dual load index plus load range |
195/70R15C 104/102R |
metric | 195/70R15C 104/102R |
parsed | commercial suffix |
33x12.50R15 |
flotation | 33X12.50R15 |
parsed | no aspect ratio exists |
35X12.50R20LT |
flotation | 35X12.50R20LT |
parsed | trailing service type |
7.50-16 |
numeric | 7.50-16 |
parsed | hyphen means bias-ply |
7.50R16 |
numeric | 7.50R16 |
parsed | radial sibling |
FR78-14 |
alphanumeric | FR78-14 |
parsed | letter is a size class |
190/65R390 |
millimetric | 190/65R390 |
parsed | rim in millimetres |
285/32R15 |
metric | — | implausible | tier two rejection |
17x7.5 +35 |
— | — | wheel | not a tire size |
245/40R18 275/35R18 |
metric ×2 | two rows | staggered | two axles, one field |
225/45R17 or 235/45R17 |
metric ×2 | — | ambiguous | user is asking, not stating |
2254517 |
metric | 225/45R17 |
low confidence | digits only, needs confirmation |
TIRE SIZE 225/65R17 240 kPa |
metric | 225/65R17 |
parsed | extracted from sticker paste |
205/55R16 91 |
metric | — | ambiguous | load index without speed symbol |
That last row is worth pausing on, because it is the kind of case that separates a thoughtful parser from a clever one. 91 on its own could be a load index missing its speed symbol, or it could be a typo, or it could be somebody's shorthand for a 91-series something that does not exist. There is no correct answer available to the machine. The right behaviour is to parse the dimensional part with high confidence, mark the trailing token unresolved, and ask.
One Field, Two Axles: Staggered Fitments
Performance cars run different sizes front and rear, and the person typing does not know your input field expects exactly one size. What arrives is 245/40R18 275/35R18, or F 245/35R19 R 275/30R19, or 245/40/18 front and 275/35/18 rear, or two sizes separated by a comma, a slash, an ampersand, or the word "and".
The wrong response is to reject it as malformed. The user gave you more information than you asked for, and punishing that is bad design. The right response is a segmentation pass before family classification: scan the token stream for more than one complete size-shaped run, split, parse each independently, and return a structured multi-size result with positional hints when the input contains them.
AXLE_HINTS = {
"F": "front", "FRONT": "front", "FR": "front",
"R": "rear", "REAR": "rear", "RR": "rear",
}
def segment_sizes(text: str) -> list[tuple[Optional[str], str]]:
parts = re.split(r"\s*(?:,|&|\bAND\b|\bPLUS\b)\s*", text)
runs: list[tuple[Optional[str], str]] = []
for part in parts:
for m in re.finditer(
r"(?:(?P<hint>\bF\b|\bFRONT\b|\bR\b|\bREAR\b|\bRR\b)\s*:?\s*)?"
r"(?P<size>\d{2,3}\s*[/X]\s*\d{1,2}(?:\.\d{2})?\s*[A-Z-]{1,2}\s*\d{2}(?:\.5)?"
r"(?:\s*\d{2,3}(?:/\d{2,3})?\s*[A-Z]{1,2})?)",
part,
):
runs.append((AXLE_HINTS.get(m.group("hint") or ""), m.group("size")))
return runs
That \bR\b hint token is a landmine and worth naming: R means "rear" in this context and "radial" three characters later in the same string. The only thing that disambiguates them is position — a standalone R bounded by whitespace before a digit run is an axle hint, an R wedged between an aspect ratio and a rim diameter is a construction marker. This is precisely the kind of context sensitivity that a single regex cannot express cleanly and a two-pass approach handles without comment.
When two sizes come back and no axle hint is present, do not guess which is front. Order in the string is a weak signal, not a fact. Return both with axle = null and let the interface ask a one-tap question. A staggered set that gets fitted backwards is a genuinely expensive mistake, and the guess saves the user roughly two seconds.
When Someone Types a Wheel Size Instead
17x7.5, 18x8.5 +35, 5x114.3, 17x7.5J ET35. These are wheel dimensions and bolt patterns, not tire sizes, and they show up in a tire size field constantly because both live on the same corner of the vehicle and the vocabulary overlaps.
The good news is they are easy to detect. Wheel sizes have a small first number — a diameter in inches, typically 13 to 24 — where a metric tire size has a three-digit millimetre width. A bolt pattern has a small integer, an x, and a decimal like 114.3 or 120.65 that is far too large to be a section width in inches. An offset carries a signed + or the literal ET.
def looks_like_wheel(tokens: list[Token]) -> Optional[str]:
nums = [t for t in tokens if t.kind in ("integer", "decimal")]
texts = [t.text for t in tokens]
if any(t.upper().startswith("ET") for t in texts) or any(t == "+" for t in texts):
return "wheel_offset"
if len(nums) >= 2 and nums[0].kind == "integer":
first = int(nums[0].text)
second = float(nums[1].text)
if 4 <= first <= 6 and second > 90:
return "bolt_pattern"
if 12 <= first <= 26 and 4.0 <= second <= 14.0:
return "wheel_size"
return None
Detecting this is worth real effort, because the response can be genuinely helpful rather than merely correct. "That looks like a wheel size — 17 inch diameter, 7.5 inch width. What size tire is currently on it, or what vehicle is it going on?" turns a dead end into the next step. A bare "invalid input" turns it into an abandoned session. And the same reasoning applies to a customer pasting the whole door-jamb placard block with pressures and load figures wrapped around the size: extract what you recognize, ignore the rest, and say what you extracted.
Typeahead Wants a Different Index Than Exact Match
Typeahead has a hostile requirement: it must return results from a partial string that is, by definition, not a valid size yet. 225 is a prefix of hundreds of sizes. 225/4 is a prefix of dozens. 2254 is a prefix of nothing under any grammar, and is also exactly what somebody types when their phone keyboard is fighting them.
So the search index cannot be the canonical form alone. I would build three keys per canonical size and index all of them:
alter table size_canonical
add column digits_key text generated always as
(regexp_replace(canonical, '[^0-9]', '', 'g')) stored,
add column compact_key text generated always as
(replace(replace(canonical, '/', ''), ' ', '')) stored;
create index size_digits_prefix_idx
on size_canonical (digits_key text_pattern_ops);
create extension if not exists pg_trgm;
create index size_canonical_trgm_idx
on size_canonical using gin (canonical gin_trgm_ops);
digits_key for 225/45R17 94V is 2254517 94, so a typed 2254 prefix-matches immediately with a plain B-tree index and no full scan. compact_key handles the case where someone types the letters but no separators. The trigram index is the fallback for genuinely fuzzy input, and it is deliberately last, because trigram similarity on short numeric strings is noisy and will happily tell you that 205/55R16 and 215/55R16 are close, which is true typographically and dangerous physically.
Ranking matters more than recall here. For a prefix query, I would rank by whether the size is catalogued at all, then by how commonly it appears across a real vehicle population, then lexically. Sizes that are common on cars driving around Calgary in the winter changeover rush should surface before obscure ones, because typeahead is a latency optimization for the common case, not a completeness guarantee.
Suggesting Something Useful After a Failed Parse
When the parse fails at tier one or tier two, the worst possible response is a red border and the word "invalid." The user cannot fix what they cannot see, and they typed what was on their sidewall.
Three suggestion strategies, in the order I would try them:
Positional feedback first. The tokenizer knows where it stopped. "I read 225 as the width and 45 as the aspect ratio, then found Q17 where I expected a construction letter and a rim diameter." That message alone resolves a large share of failures without any suggestion logic at all, because the user immediately sees the typo.
Numeric neighbourhood second. If the string parses syntactically but fails plausibility, find catalogued sizes near it in field space, not in string space. Distance on (width, aspect, rim) with each axis weighted by how much a change there affects the vehicle — rim diameter changes are severe, aspect changes are moderate, width changes are mild.
select canonical,
abs(section_width_mm - :w) * 1.0
+ abs(aspect_ratio - :a) * 2.5
+ abs(rim_diameter_in - :r) * 40.0 as distance
from size_canonical
where family = 'metric'
and rim_diameter_in between :r - 1 and :r + 1
order by distance asc
limit 5;
The weights are the entire content of that query and they are a domain judgment, not a tuning parameter. A rim diameter that is off by one is almost always a typo worth surfacing; an aspect ratio off by five is a different tire but a plausible mistake; a width off by ten is often just a rounding of memory.
Trigram last. Only when the string does not parse at all does string similarity earn its place, and even then the results should be labelled as guesses rather than presented as matches.
Ambiguity You Cannot Resolve, and How to Admit It
Some inputs have no correct answer, and the professional move is to say so rather than to be confidently wrong.
2254517 is the clearest case. It is almost certainly 225/45R17. It could also be 225/45R17 with a truncated service description, or a mistyped 225/40R17, or in principle a 22/54/517 that means nothing. High confidence is not certainty, and the difference should be visible: parse it, mark confidence low, echo the interpretation back in full form, and require one tap to confirm. The user's cost is one tap. The alternative cost is a wrong set of tires on a vehicle.
205/55R16 91 is the second case, and it is genuinely unresolvable — a bare load index with no speed symbol is under-specified and no heuristic recovers the missing letter.
The third case is the sneaky one: a string that parses cleanly under two families. A decimal-bearing string with no slash can be numeric or flotation. 8.75R16.5 is a numeric size; 8.75x16.5 would be something else entirely. When both branches match, return both candidates, sorted by prior likelihood, and let the interface present a two-option question with human-readable descriptions rather than raw strings.
The general principle, stated as a rule I would put in a design document: the parser may return zero, one, or many interpretations, and the API type must make "many" as easy to handle as "one." If your return type is Optional[TireSize], you have already forced every ambiguity to become a silent guess, because there is nowhere else for the second candidate to go. Change the type before you change the logic.
And there is a category that is not the parser's problem at all. If a customer describes uneven wear, a pull to one side, or a vibration that appears at a particular speed, no amount of string parsing helps — that is a physical diagnosis. Some of it is tire-side and gets sorted out by balancing the assembly or determining whether a puncture is repairable. But alignment, suspension, steering, and brake work are outside what a tire-and-oil-change business does, and the correct answer is a referral to a qualified mechanic rather than a guess dressed up as a service. Knowing the boundary of your own scope is the same discipline as knowing the boundary of your grammar.
Where the Fleet Case Diverges
Commercial and fleet data has a different failure profile, and it is worth a paragraph because it changes priorities.
A fleet record is not a search box. It is a durable asset record that gets read years after it was written, often by someone who was not there when it was entered. That inverts the tolerance calculus: for a search box, a low-confidence guess with a confirm step is fine, because the human is right there. For a fleet record, a low-confidence guess becomes a permanent fact that nobody re-verifies, and it will be used to order tires for twelve trucks.
So for durable records I would refuse to store anything below high confidence without explicit human confirmation, and I would store the confirming actor's identity alongside it. The same parser, a different threshold, and a different write policy. Commercial sizes also skew toward the forms most likely to be mis-parsed — C-type suffixes, dual load indexes, load range letters, 19.5 inch rims — so the families that matter least for passenger changeover volume matter most here.
What I Would Build First, In Order
Not all of the above at once. Ranked by value per hour of work:
- The raw-input table. Before any parsing improvement, log every string anyone types, with a channel and a timestamp. A week of real inputs will reorder every other priority on this list, and it costs an afternoon.
- The tokenizer with position tracking. Positional error messages are the highest-leverage user-facing improvement in the whole pipeline and they fall out of the scanner for free.
- Metric and flotation patterns only. These two cover the overwhelming majority of passenger and light-truck traffic. Ship them, measure the failure rate, then decide whether legacy families are worth it.
-
Canonical form plus the
satisfiescomparison. Get equality right early, because every downstream feature depends on it and retrofitting directional matching into a codebase full of==is miserable. - The diameter plausibility check. Twelve lines of arithmetic that catch a real class of typos before they become orders.
- The digits-only index. Cheap, and it makes typeahead feel instant.
Not on the list: the cross-reference table, fuzzy suggestion, and millimetric support. Each is defensible eventually, and none of them earns its complexity until the raw-input log proves people are actually hitting those cases. Build the logging first and let the data pick.
Questions That Come Up in Review
Why not just use a library? Do, if one fits your market and your families. Read its test suite first, specifically for flotation, ZR, dual load indexes, and extra-load handling. Most of the ones I have looked at handle the metric family well and treat everything else as an afterthought, which is fine right up until a trailer arrives.
Is storing both raw and canonical redundant? No. They answer different questions. Canonical answers "what did they mean." Raw answers "what did they do." You need both, and the second one is the only one that survives a parser bug.
Should the parser normalize P225/45R17 to 225/45R17? No. They are dimensionally identical and standards-wise distinct, and the P is information the user gave you. Record the relationship in the cross-reference table where it can carry a reason, rather than deleting a character and hoping.
How do you version the parser safely? Stamp parser_version on every input row, keep old versions importable, and make re-parsing the entire raw corpus a routine batch job rather than an emergency procedure. If replaying your whole input history is a scary operation, your parser is effectively unfixable.
What about tires with no size at all? They exist — some industrial, agricultural, and specialty items are identified by part number rather than a dimensional string. Model the size as nullable and the part identifier as the real primary key. A browsable catalogue of consumer sizes can pretend otherwise; a general inventory system cannot.
Does any of this matter for a small business? The parsing rigour scales down further than you would expect. Even a single-location Calgary operation handling winter fitments and mounting sets for customers across the city benefits from the same three things: log the raw string, never guess silently, and make ambiguity visible instead of resolving it by coin flip. The schema can be two tables and the parser can be two hundred lines. The discipline is what carries.
The Actual Lesson
Tire sizes are a good teaching example because they compress a lot of general identifier-parsing pain into a domain small enough to hold in your head. Overlapping standards nobody retired. Units that switch mid-string. Optional fields that are semantically load-bearing. Strings that are valid under two grammars. Legacy notations with real users. A user population that types under bad conditions and is not wrong to.
The architecture that survives all of that is not a cleverer regex. It is a layered pipeline where each stage does one thing and reports honestly on what it could not do: a scanner that never fails and always knows its position, a classifier willing to return multiple hypotheses, small per-family grammars that can be read and deleted independently, a canonical form that lives beside the raw input rather than replacing it, plausibility checks separated from syntax checks separated from availability checks, and an output type that treats ambiguity as a first-class result rather than an error.
Every one of those choices costs a little more up front than the mega-regex. Every one of them pays for itself the first time a real person types something you did not anticipate, which will happen on approximately day two.
Top comments (0)