{"id":51,"date":"2026-09-20T00:41:42","date_gmt":"2026-09-20T00:41:42","guid":{"rendered":"https:\/\/zyzxsolutions.com\/?p=51"},"modified":"2026-09-20T00:41:42","modified_gmt":"2026-09-20T00:41:42","slug":"address-fields-are-where-forms-go-to-die","status":"publish","type":"post","link":"https:\/\/zyzxsolutions.com\/?p=51","title":{"rendered":"Address fields are where forms go to die"},"content":{"rendered":"<p>Ask anyone who has ever cleaned a customer database what the worst field is and they will say the same thing: the city. Not the email, not the phone number \u2014 the city, because it is the field people get wrong without ever realising they got it wrong.<\/p>\n<p>This is a long post about a small plugin. It covers why address entry fails, what the data actually looks like underneath, the three approaches we tried before settling on the fourth, and what it costs to do it properly. If you have ever exported a customer list and found &#8220;Springfeild&#8221;, this is for you.<\/p>\n<h2>The problem, concretely<\/h2>\n<p>A typical checkout or quote form asks for street, city, state and ZIP. Four free-text fields, four opportunities for a typo, and no relationship between them enforced anywhere. What comes out the other side looks like this:<\/p>\n<ul>\n<li><strong>Springfield, OH 45503<\/strong> \u2014 correct.<\/li>\n<li><strong>Springfeild, OH 45503<\/strong> \u2014 one transposition, and now your city grouping has two Springfields.<\/li>\n<li><strong>Springfield, OK 45503<\/strong> \u2014 a mis-selected state. The ZIP says Ohio. Your shipping calculator says Oklahoma.<\/li>\n<li><strong>Springfield, OH 45053<\/strong> \u2014 transposed ZIP. That is a different county, a different tax jurisdiction, and possibly a different service area.<\/li>\n<\/ul>\n<p>Every one of those submits cleanly. No validation catches them, because each field is individually plausible. You find out weeks later when something is delivered to the wrong place or a tax return does not reconcile.<\/p>\n<h3>The second-order costs<\/h3>\n<p>The wrong address is only the beginning. Downstream, you get:<\/p>\n<ul>\n<li><strong>Broken segmentation.<\/strong> &#8220;Customers in Clark County&#8221; quietly misses everyone whose county was inferred from a bad ZIP.<\/li>\n<li><strong>Tax errors.<\/strong> In states with local sales tax, the jurisdiction is determined by address. A wrong ZIP is a wrong rate.<\/li>\n<li><strong>Service-area mistakes.<\/strong> If you quote differently inside and outside a radius, a bad postcode quotes the wrong price.<\/li>\n<li><strong>Duplicate records.<\/strong> Two spellings of one city become two customers.<\/li>\n<li><strong>Form abandonment.<\/strong> Every extra field is friction, and address blocks are the longest part of most forms.<\/li>\n<\/ul>\n<h2>What the data actually looks like<\/h2>\n<p>The useful insight is that <strong>the ZIP code already contains everything else<\/strong>. Five digits determine, unambiguously, the city, the state, the county, the approximate coordinates and the timezone. If you have the ZIP, asking for the city is asking the user to re-enter information you already have \u2014 and giving them a chance to get it wrong.<\/p>\n<p>There are roughly 41,000 US ZIP codes in active use. The full dataset with city, state, county, latitude, longitude and timezone is a few megabytes. That is small enough to sit in a database table on any host you are likely to be using.<\/p>\n<h3>The awkward edges<\/h3>\n<p>It is not quite as clean as that paragraph implies, and this is where most implementations fall over:<\/p>\n<ul>\n<li><strong>One ZIP, several place names.<\/strong> A ZIP often has one official city plus several acceptable alternates. Pick the wrong one as your default and locals will think your form is broken.<\/li>\n<li><strong>ZIPs that cross county lines.<\/strong> Uncommon but real, and it matters for tax.<\/li>\n<li><strong>ZIPs that cross state lines.<\/strong> Rare, but they exist.<\/li>\n<li><strong>PO box and unique ZIPs.<\/strong> Some ZIPs belong to a single large organisation and have no residential meaning at all.<\/li>\n<li><strong>Timezone is not derivable from the state.<\/strong> Ask anyone in Indiana.<\/li>\n<\/ul>\n<h2>Three approaches that did not survive<\/h2>\n<h3>1. Call a third-party API<\/h3>\n<p>The obvious answer, and the one we shipped first. It works beautifully until one of four things happens: the free tier runs out, the vendor rate-limits you mid-checkout, the vendor has an outage and takes your form down with it, or someone in legal asks why customer addresses are being sent to a company nobody has a contract with.<\/p>\n<p>The latency is the quiet problem, though. A round trip to an external API is 100\u2013400ms. Fire that on every keystroke and the field feels laggy; fire it on blur and the user has already tabbed past.<\/p>\n<h3>2. Ship a flat file<\/h3>\n<p>A JSON file of every ZIP, loaded into memory. Simple, no dependencies \u2014 and a multi-megabyte parse on every request, which is exactly the kind of thing that makes people blame WordPress for being slow.<\/p>\n<h3>3. Autocomplete the city instead<\/h3>\n<p>Let people type the city and suggest matches. This fixes spelling but not the relationship between fields \u2014 you can still pick Springfield, Ohio and then type an Illinois ZIP underneath it, and nothing objects.<\/p>\n<h2>What we settled on<\/h2>\n<p>An indexed database table, queried locally, triggered on the ZIP field.<\/p>\n<p>The plugin creates one table at activation and imports the dataset once. The lookup is a single indexed query on a five-character primary key \u2014 sub-millisecond, on any host, every time. The ZIP field fires the lookup at five digits, and the city, state, county and timezone fields fill themselves in.<\/p>\n<p>Where a ZIP has multiple acceptable city names, the official one is filled in and the alternates are offered in a dropdown rather than hidden. Where a ZIP crosses a county line, the primary county is returned and the alternate is exposed to code that cares. Nothing is guessed silently.<\/p>\n<h3>Working with forms that were not built for it<\/h3>\n<p>The genuinely fiddly part was never the data. It was that every form plugin names its fields differently, and half of them re-render the DOM after you have bound to it. The plugin binds by event delegation rather than direct listeners, so fields that appear after page load \u2014 conditional sections, repeatable groups, multi-step forms \u2014 behave the same as fields that were there from the start.<\/p>\n<p>Field mapping is one filter:<\/p>\n<pre><code>add_filter( 'zyzx_zip_field_map', function ( $map ) {\n    $map['city']     = 'billing_city';\n    $map['state']    = 'billing_state';\n    $map['county']   = 'billing_county';\n    $map['timezone'] = 'appointment_tz';\n    return $map;\n} );<\/code><\/pre>\n<h2>What it is worth<\/h2>\n<p>The honest case for this is not that typing a city is hard. It is that:<\/p>\n<ul>\n<li>Four fields become one for the user, which measurably reduces abandonment on long forms.<\/li>\n<li>The city, state and county can no longer disagree with the ZIP, because they are derived from it.<\/li>\n<li>You get county and timezone, which almost nobody asks for and plenty of businesses need.<\/li>\n<li>There is no API key, no per-lookup cost, no rate limit and no third party in the path of your checkout.<\/li>\n<li>It keeps working when the internet does not.<\/li>\n<\/ul>\n<h2>Where it is going<\/h2>\n<p>Canadian postal codes are the most requested addition and are in progress. UK postcodes are harder \u2014 the data licensing is genuinely complicated \u2014 but they are on the list. Address-line autocomplete is not planned, because that genuinely does need a live service, and the entire point of this plugin is not needing one.<\/p>\n<p><strong>ZIP Autofill<\/strong> is in the catalog now. One-time price, local data, quarterly refreshes included.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Why city, state and ZIP disagree with each other in every customer database, what the postal data actually looks like underneath, and the four approaches we tried before one of them worked.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[68,1,62],"tags":[70,69,73,71,72],"class_list":["post-51","post","type-post","status-publish","format-standard","hentry","category-build-log","category-uncategorized","category-wordpress","tag-data-quality","tag-forms","tag-plugins","tag-ux","tag-zip-codes"],"_links":{"self":[{"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/posts\/51","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=51"}],"version-history":[{"count":0,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=\/wp\/v2\/posts\/51\/revisions"}],"wp:attachment":[{"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=51"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=51"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/zyzxsolutions.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=51"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}