<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Srdjan Popovic</title>
    <description>The latest articles on DEV Community by Srdjan Popovic (@srdjan_poppovic).</description>
    <link>https://dev.to/srdjan_poppovic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4084590%2F3827c69d-536d-4d50-a049-580636932240.jpg</url>
      <title>DEV Community: Srdjan Popovic</title>
      <link>https://dev.to/srdjan_poppovic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/srdjan_poppovic"/>
    <language>en</language>
    <item>
      <title>Ten Containers, One Developer: The Architecture of a Web GIS</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Thu, 03 Sep 2026 10:43:50 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/ten-containers-one-developer-the-architecture-of-a-web-gis-32io</link>
      <guid>https://dev.to/srdjan_poppovic/ten-containers-one-developer-the-architecture-of-a-web-gis-32io</guid>
      <description>&lt;p&gt;I built a web GIS by myself. It holds 2.7 million road features, 51 GB of LiDAR and imagery, and runs as ten containers on one machine.&lt;/p&gt;

&lt;p&gt;This is the map of it: what each piece does, why the boundaries are where they are, and — since I've spent several posts on this — where I got it wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Road inventory. Surveyors drive a vehicle with a LiDAR scanner and a panoramic camera, and the result is a catalogue of everything alongside a road: signs, poles, kerbs, drains, cameras, bins, street lights. Users then work with that catalogue in a browser — draw, correct, measure, export.&lt;/p&gt;

&lt;p&gt;Two things about that shape drive every decision below:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Users edit.&lt;/strong&gt; This isn't a published dataset that refreshes nightly. Someone moves a sign and expects the map to show it moved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The binary dwarfs the records.&lt;/strong&gt; The database is 2.3 GB. One survey is a gigabyte on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The containers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;proxy        TLS, routing, caching
frontend     the browser app
backend      Django + DRF — auth, projects, layers, features
importer     FastAPI — uploads, imports, exports, mosaics
martin       vector tiles from PostGIS
titiler      raster tiles from Cloud-Optimised GeoTIFFs
db           PostgreSQL + PostGIS
pgbouncer    connection pooling
file-drop    bulk data ingest
ci           builds and deploys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten containers for one developer looks like over-engineering until you notice most of them are off-the-shelf processes doing one job. Only three contain code I wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the boundaries are where they are
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Django and FastAPI, both.&lt;/strong&gt; Not fashion — a split by workload. Django holds the domain: users, roles, projects, layers, feature CRUD. It's a request/response application and the ORM and admin earn their keep.&lt;/p&gt;

&lt;p&gt;Imports are not request/response. A 200 MB shapefile occupies a worker for minutes, and a synchronous endpoint gives the client no way to ask how it's going. So that work went to a separate async service with a job-and-poll shape, &lt;code&gt;asyncio.to_thread&lt;/code&gt; for the blocking GDAL calls, and a semaphore to bound concurrency — because heavy geospatial work is memory-bound, and unbounded parallelism converts "slow" into "OOM-killed". → &lt;em&gt;&lt;a href="https://dev.to/srdjan_poppovic"&gt;A Shapefile Is Four Files&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two tile servers.&lt;/strong&gt; Martin renders vector tiles from PostGIS live. TiTiler serves raster tiles from COGs on disk. They share nothing but the reverse proxy, because vector and raster have nothing in common at this layer: one is a query, the other is a byte range in a file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Features live in three tables, not one per layer.&lt;/strong&gt; Points, lines and polygons, each with a &lt;code&gt;layer_id&lt;/code&gt;. A table per layer would mean DDL every time a user clicks "new layer". The cost lands on tiles: Martin publishes &lt;em&gt;views&lt;/em&gt;, so every layer gets a generated view filtered to its id, and &lt;code&gt;auto_publish&lt;/code&gt; picks it up within five seconds. Create a layer in the browser, it's a live tile endpoint before you've finished naming it. → &lt;em&gt;&lt;a href="https://dev.to/srdjan_poppovic"&gt;One View Per Layer&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Big binary never enters the database.&lt;/strong&gt; Point clouds, panoramas and orthophotos sit on disk and are served as static files. The database holds the trajectory — the line the survey vehicle drove — plus metadata and paths. That trajectory is 0.01% of the bytes and answers every question anyone asks. → &lt;em&gt;&lt;a href="https://dev.to/srdjan_poppovic"&gt;One Gigabyte per Survey&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pgbouncer in front of Postgres.&lt;/strong&gt; Martin alone opens a hundred connections. Postgres does not enjoy that; a pooler does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One pipeline per service repo.&lt;/strong&gt; Push to the deploy branch, the image is built and the service restarted. Keeping each service in its own repository with its own pipeline means deploys are independent — a change to the tile server config never waits on a backend build, and a broken pipeline takes one service out of date rather than all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things I got wrong
&lt;/h2&gt;

&lt;p&gt;Writing these posts turned into an audit, and the audit found more than the posts did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cache with no TTL isn't a cache.&lt;/strong&gt; Martin's built-in tile cache has no invalidation hook. A tile cached before an edit is served, byte-identical, for the life of the process — I measured the same 242 B tile long after the geometry under it had changed. It's off now, and nginx caches the same tiles with a five-second TTL instead, which absorbs the burst from panning a map without ever showing yesterday's geometry. → &lt;em&gt;&lt;a href="https://dev.to/srdjan_poppovic"&gt;My Tile Cache Has No Invalidation&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Users write SQL identifiers.&lt;/strong&gt; Layer names become view names, and they were being interpolated into DDL with an f-string, unquoted and unvalidated. The tell wasn't a security scan — it was a layer somebody had named &lt;code&gt;1&lt;/code&gt;, whose view silently failed to exist because an unquoted identifier can't start with a digit. The same string that breaks the syntax could have completed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One dead container stopped nginx from starting.&lt;/strong&gt; &lt;code&gt;proxy_pass&lt;/code&gt; with a literal hostname resolves at config load, and nginx refuses to start if any name is missing. A stopped background service took down the entire entry point. Moving addresses into variables fixes it — and then quietly breaks trailing-slash path stripping, and then quietly breaks any &lt;code&gt;if&lt;/code&gt; block sitting below the &lt;code&gt;rewrite&lt;/code&gt; you added to compensate. → &lt;em&gt;&lt;a href="https://dev.to/srdjan_poppovic"&gt;One Dead Container&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dropdown had the wrong EPSG code.&lt;/strong&gt; Two coordinate systems in this region describe the same Gauss-Krüger zone with identical projection parameters and different datums — 427 metres apart, which lands in the right city and is therefore the dangerous kind of wrong. A third option was labelled as that zone but was actually a neighbouring country's grid, 5,000 km out. → &lt;em&gt;&lt;a href="https://dev.to/srdjan_poppovic"&gt;Same Zone, Same Projection, 427 Metres Apart&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What building it alone actually changes
&lt;/h2&gt;

&lt;p&gt;Not the architecture. The boundaries above are the ones I'd argue for on a team.&lt;/p&gt;

&lt;p&gt;What changes is &lt;strong&gt;who notices&lt;/strong&gt;. Every one of those four defects had been in production for months, behind an error that was caught and logged and never read. No code review would have caught the EPSG label — you'd have to know the region. But a second person wondering aloud why one layer never renders would have found the injection in an afternoon.&lt;/p&gt;

&lt;p&gt;So the thing I'd tell anyone in the same position: &lt;strong&gt;you are the code review, and you have to schedule it.&lt;/strong&gt; Not "read your own code" — that finds nothing. Pick a subsystem, write down what it does as if explaining it to somebody, and check every claim against the running system as you go.&lt;/p&gt;

&lt;p&gt;I set out to write a blog post about a tile server. What I got was a security fix, four bugs, and a config file that no longer has a password in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, plainly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Django, DRF, PostGIS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import/export&lt;/td&gt;
&lt;td&gt;FastAPI, geopandas, GDAL, async jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector tiles&lt;/td&gt;
&lt;td&gt;Martin, one view per layer, auto-publish&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raster tiles&lt;/td&gt;
&lt;td&gt;TiTiler over COGs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL + PostGIS, pgbouncer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge&lt;/td&gt;
&lt;td&gt;nginx — TLS, routing, tile and raster caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI&lt;/td&gt;
&lt;td&gt;one pipeline per service repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;2.7M features · 102 layers · 51 GB imagery and point clouds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>architecture</category>
      <category>gis</category>
      <category>postgres</category>
      <category>docker</category>
    </item>
    <item>
      <title>One Gigabyte per Survey, of Which 108 KB Goes in the Database</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Thu, 27 Aug 2026 15:32:20 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/one-gigabyte-per-survey-of-which-108-kb-goes-in-the-database-2bf7</link>
      <guid>https://dev.to/srdjan_poppovic/one-gigabyte-per-survey-of-which-108-kb-goes-in-the-database-2bf7</guid>
      <description>&lt;p&gt;Here is the disk layout of one mobile mapping survey — a vehicle with a LiDAR scanner and a panoramic camera, driven along a road:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data/001_MMS/            507 MB    point cloud
orbit/oblak/             566 MB    spherical photos
trajectory/*.gpkg        108 KB    the path the vehicle drove
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just over a gigabyte. The database this feeds holds &lt;strong&gt;2.3 GB in total&lt;/strong&gt; — for 2.7 million road features across a hundred layers. Two more surveys and the binary data outweighs everything the database has ever stored.&lt;/p&gt;

&lt;p&gt;So the question isn't how to put a point cloud in Postgres. It's what you put in Postgres &lt;em&gt;instead&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trajectory is the index
&lt;/h2&gt;

&lt;p&gt;Of that gigabyte, one file goes into the database: the 108 KB trajectory, a GeoPackage holding the line the vehicle drove.&lt;/p&gt;

&lt;p&gt;That line is what makes the survey findable. It draws on the map with everything else. You can ask which surveys cover a junction, which are newest, whether a stretch of road has been captured since the resurfacing. All the questions people actually ask are questions about &lt;em&gt;where and when&lt;/em&gt;, and the trajectory answers every one of them at 0.01% of the storage.&lt;/p&gt;

&lt;p&gt;The heavy files never enter the database. The row holds paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Cloud&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db_index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;path_name&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# -&amp;gt; octree metadata JSON
&lt;/span&gt;    &lt;span class="n"&gt;orbit_url&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# -&amp;gt; spherical photo index
&lt;/span&gt;    &lt;span class="n"&gt;spherical_photo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BooleanField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;recording_date&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;source_srid&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntegerField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SOURCE_SRID_CHOICES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;available&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BooleanField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Metadata, geometry, and pointers. That's the whole trick, and it isn't clever — it's just the discipline to not reach for a &lt;code&gt;bytea&lt;/code&gt; column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not in the database
&lt;/h2&gt;

&lt;p&gt;Postgres will happily store a gigabyte. It's the access pattern that kills you.&lt;/p&gt;

&lt;p&gt;A browser point cloud viewer doesn't fetch a point cloud. It fetches an &lt;strong&gt;octree&lt;/strong&gt;: a tree of small files, and as the user moves the camera it pulls the nodes covering what's in view at the detail level that's visible. Zoom in, it fetches deeper nodes. Pan away, it drops them. A single session issues hundreds of small ranged reads driven by mouse movement.&lt;/p&gt;

&lt;p&gt;That is precisely the workload a static file server is built for, and precisely the one a database connection pool is not. Serving it through Django would mean an application worker occupied for every node fetch, connection pool pressure from mouse movement, and no benefit whatsoever — there is no query, no join, no permission decision per node beyond the one already made when the survey was opened.&lt;/p&gt;

&lt;p&gt;nginx serves the directory directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/media/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;/app/media/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# CORS - needed for local frontend dev servers (different origin/port)&lt;/span&gt;
    &lt;span class="c1"&gt;# fetching point cloud (Potree octree/hierarchy) and other media files&lt;/span&gt;
    &lt;span class="c1"&gt;# directly via fetch()/XHR. No credentials involved, so a wildcard is safe.&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;'Access-Control-Allow-Origin'&lt;/span&gt; &lt;span class="s"&gt;'*'&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That wildcard needs the justification written next to it, which is why the comment is there. It's safe &lt;em&gt;because&lt;/em&gt; no credentials ride along: the octree nodes are opaque binary that mean nothing without the metadata, and the metadata comes from the authenticated API. Change either of those facts and the wildcard becomes a mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the size actually goes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;27 GB   orthophotos
24 GB   prepared point clouds
74 MB   symbology (icons for signs, poles, cameras)
1.5 MB  project thumbnails
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The orthophotos are the bigger half, and they follow a different path — served as Cloud-Optimised GeoTIFFs through a raster tile server, cached at nginx for a day. Different data, different access pattern, different tool. What they share is that neither one is in Postgres.&lt;/p&gt;

&lt;p&gt;Note the shape of the tail: two entries measured in gigabytes, everything else in megabytes. That's typical, and it's the argument for treating "large binary" as its own tier rather than a column type. The 74 MB of symbology icons &lt;em&gt;could&lt;/em&gt; live in the database without anyone noticing. The 24 GB could not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The escape hatch, and what it costs
&lt;/h2&gt;

&lt;p&gt;Not all data can be copied. Some surveys are enormous and already sitting on a storage array, and duplicating them to bring them into the system is not worth 500 GB.&lt;/p&gt;

&lt;p&gt;So there's a symlink path: point the system at data that lives elsewhere, and it appears under &lt;code&gt;media/external/&amp;lt;hash&amp;gt;/&lt;/code&gt; as if it had been uploaded.&lt;/p&gt;

&lt;p&gt;This works, and it has a cost that must be paid explicitly. A symlink is a reference the database doesn't own, so deleting the row has to clean up the link too — otherwise &lt;code&gt;media/external&lt;/code&gt; slowly fills with pointers to nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@receiver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post_delete&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Cloud&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delete_cloud_symlinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Remove the symlink folders under media/external when a Cloud is deleted.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every "just point at the existing files" shortcut buys you disk and sells you a lifecycle problem. Worth it here — &lt;code&gt;media/external&lt;/code&gt; is 116 KB of links standing in for far more — but the cleanup is not optional, and a signal is the cheapest place to guarantee it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that has to be asked for
&lt;/h2&gt;

&lt;p&gt;One more field in that model earns its place: &lt;code&gt;source_srid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trajectory arrives in whatever coordinate system the surveyor worked in, and — as with shapefiles that ship without a &lt;code&gt;.prj&lt;/code&gt; — the file often doesn't say. So the model offers the choices, and the pipeline prefers the file's declared CRS, falls back to the user's selection, and refuses to guess.&lt;/p&gt;

&lt;p&gt;That dropdown is small and easy to get wrong. I &lt;a href="https://dev.to/srdjan_poppovic"&gt;found one of its options labelled with an EPSG code from the wrong country&lt;/a&gt;, which would have put a trajectory about 5,000 km from the road it was recorded on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Put in the database what you'll query. Put on disk what you'll stream.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For survey data that means: the trajectory, the recording date, the coordinate system, the paths, and a flag for whether the thing is ready to view. Not the octree, not the panoramas, not the orthophoto.&lt;/p&gt;

&lt;p&gt;The test I'd apply to any large asset: &lt;em&gt;is there a question someone will ask that requires this to be in a table?&lt;/em&gt; For a point cloud the honest answer is no — every question is about the trajectory, which is 0.01% of the bytes and answers all of them.&lt;/p&gt;

</description>
      <category>gis</category>
      <category>postgres</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Shapefile Is Four Files, and the Important One Is Optional</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Wed, 26 Aug 2026 11:10:44 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/a-shapefile-is-four-files-and-the-important-one-is-optional-hc9</link>
      <guid>https://dev.to/srdjan_poppovic/a-shapefile-is-four-files-and-the-important-one-is-optional-hc9</guid>
      <description>&lt;p&gt;A user clicks "upload" and picks a file. In most systems that sentence is the whole story.&lt;/p&gt;

&lt;p&gt;In a geospatial system, the thing they picked is one of four files that only mean something together, the one carrying the most important piece of information is optional and frequently absent, and the geometry inside it almost certainly doesn't match your database schema.&lt;/p&gt;

&lt;p&gt;This is what I learned building the ingest path for a web GIS that now holds about 2.7 million features.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: it left Django
&lt;/h2&gt;

&lt;p&gt;Import used to be a synchronous Django endpoint. Upload a file, the request thread parses it, writes rows, returns.&lt;/p&gt;

&lt;p&gt;That works until the file is 200 MB of line geometry. Then the request occupies a worker for minutes, the client times out with no way to find out whether the import survived, and a second user doing the same thing takes out a second worker. Nothing is &lt;em&gt;wrong&lt;/em&gt;; the shape is just incompatible with the work.&lt;/p&gt;

&lt;p&gt;So imports moved to a separate FastAPI service. Not because FastAPI is faster — the parsing is done by GDAL and geopandas either way, and they don't care which framework called them — but because the async model makes the right structure cheap to express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the blocking part goes to a thread, not the event loop
&lt;/span&gt;&lt;span class="n"&gt;footprints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_compute_all_footprints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tiff_list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_epsg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32634&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# and concurrency is bounded on purpose
&lt;/span&gt;&lt;span class="n"&gt;semaphore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Semaphore(4)&lt;/code&gt; is the important line. Heavy geospatial work is memory-bound before it's CPU-bound — a raster mosaic or a large shapefile can hold hundreds of megabytes while it's being read. Unbounded concurrency doesn't make imports finish sooner, it makes the container get OOM-killed while five of them are half-done.&lt;/p&gt;

&lt;p&gt;The endpoint returns a job id immediately. The client polls. There's a progression table so "importing" can say &lt;em&gt;what&lt;/em&gt; it's doing rather than just spinning.&lt;/p&gt;

&lt;p&gt;The same job-plus-poll shape is now used for raster mosaics and for exports. That consistency is worth more than any of the individual implementations: three features, one mental model, one place to look when something is stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  A file is not a file
&lt;/h2&gt;

&lt;p&gt;A shapefile is a &lt;strong&gt;set&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;REQUIRED_SHAPEFILE_EXTENSIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.shp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.shx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.dbf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.shp&lt;/code&gt; holds geometry. &lt;code&gt;.shx&lt;/code&gt; is the index into it. &lt;code&gt;.dbf&lt;/code&gt; holds the attributes. Hand a parser the &lt;code&gt;.shp&lt;/code&gt; alone and you get an error that's about a missing index, not about a missing upload.&lt;/p&gt;

&lt;p&gt;So validation runs over the &lt;em&gt;set&lt;/em&gt; of filenames before anything is parsed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_shapefile_components&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filenames&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;extensions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filenames&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;REQUIRED_SHAPEFILE_EXTENSIONS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing required shapefile components: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users mostly upload a ZIP, which the service extracts and then validates. The error message names the missing extension, because "invalid shapefile" tells someone nothing they can act on and "missing .dbf" tells them exactly what to go find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The important file is the optional one
&lt;/h2&gt;

&lt;p&gt;Notice what isn't in that required list: &lt;code&gt;.prj&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.prj&lt;/code&gt; is the file that says which coordinate system the numbers are in. Without it, a shapefile contains coordinates like &lt;code&gt;7457000, 4958000&lt;/code&gt; and no statement of what they mean. Those could be metres in one of several national grids, and picking wrong puts the data hundreds of metres — or thousands of kilometres — from where it belongs.&lt;/p&gt;

&lt;p&gt;It isn't required because it's genuinely, frequently absent. Decades of surveying data was delivered as three files, in a projection everyone in the room already knew. That knowledge lived in people, not in the archive.&lt;/p&gt;

&lt;p&gt;Which is why the upload form has to ask, and why "what coordinate system is this in?" is a dropdown the user must answer rather than something the software detects. That dropdown is its own story — I &lt;a href="https://dev.to/srdjan_poppovic"&gt;wrote about the day I found one of its options was labelled with the wrong EPSG code&lt;/a&gt;, pointing at a different country's grid.&lt;/p&gt;

&lt;p&gt;The pipeline handles it in the right order: use the file's own CRS if it declares one; fall back to what the user selected; refuse to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your schema will not match their data
&lt;/h2&gt;

&lt;p&gt;The three feature tables are typed. Deliberately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project_pointfeature     POINT            3 dims   SRID 4326
project_linefeature      MULTILINESTRING  3 dims   SRID 4326
project_polygonfeature   MULTIPOLYGON     3 dims   SRID 4326
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uploaded data essentially never arrives in that shape, so every import performs three coercions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproject to 4326.&lt;/strong&gt; One SRID in storage, always. Anything else means every query has to know what it's holding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single to multi.&lt;/strong&gt; A shapefile can contain a &lt;code&gt;Polygon&lt;/code&gt; where the next feature is a &lt;code&gt;MultiPolygon&lt;/code&gt; — the format allows both, and real files mix them. A column typed &lt;code&gt;MULTIPOLYGON&lt;/code&gt; rejects the plain one. Promoting every geometry to its multi form is a one-line transform that removes an entire class of "some features imported and some didn't".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2D to 3D.&lt;/strong&gt; This one is the least obvious and the one I'd defend hardest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_ensure_3d&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;geom&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_z&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nc"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The columns are three-dimensional, and today &lt;strong&gt;every single row has a Z value&lt;/strong&gt; — 1,820,288 points, 697,009 lines, 171,830 polygons, without exception. Not because all the source data had elevation, but because anything that didn't got lifted to &lt;code&gt;z = 0&lt;/code&gt; on the way in.&lt;/p&gt;

&lt;p&gt;That looks like storing a fake number. It's a deliberate trade: this data sits alongside LiDAR surveys where Z is real and load-bearing, and a table where &lt;em&gt;some&lt;/em&gt; geometries have Z is worse than either alternative. Every query that touches elevation would need to know which rows to trust, and PostGIS functions behave differently on mixed-dimension inputs. One dimensionality, one code path, and &lt;code&gt;z = 0&lt;/code&gt; that is explicitly a placeholder.&lt;/p&gt;

&lt;p&gt;If you have no 3D data at all, store 2D. What you should not do is let the dimension depend on whichever file happened to be uploaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd carry to the next one
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Move long work out of the request before you're forced to.&lt;/strong&gt; The rewrite is cheap while it's one endpoint and expensive once three features have grown their own half-solutions. Job id, poll, progress — same shape every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bound your concurrency explicitly.&lt;/strong&gt; For memory-heavy work, unbounded parallelism converts "slow" into "OOM-killed", which is much harder to debug because it takes unrelated jobs down with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate the set, not the file.&lt;/strong&gt; Where a format is really several artefacts, check them together and name what's missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Normalise aggressively at the boundary.&lt;/strong&gt; Reproject, promote to multi, fix the dimension — once, on the way in, where you can still reject the whole upload cleanly. Every check you skip there becomes a conditional in every query that reads the table afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask for what the file can't tell you.&lt;/strong&gt; Some information genuinely isn't in the data. Building a place for the user to supply it beats inferring it, and inferring it beats a default that's silently wrong.&lt;/p&gt;

</description>
      <category>python</category>
      <category>gis</category>
      <category>fastapi</category>
      <category>postgres</category>
    </item>
    <item>
      <title>One Dead Container Stopped nginx From Starting At All</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Tue, 25 Aug 2026 08:35:05 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/one-dead-container-stopped-nginx-from-starting-at-all-44h2</link>
      <guid>https://dev.to/srdjan_poppovic/one-dead-container-stopped-nginx-from-starting-at-all-44h2</guid>
      <description>&lt;p&gt;Eight containers behind one nginx. One of them was stopped. nginx would not start.&lt;/p&gt;

&lt;p&gt;Not "returned 502 for that service" — would not start, at all, with every other service running fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;host not found in upstream "importer"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is one dead background service taking down the entire public entry point: the frontend, the API, the tile server, all of it. This is a short post about why, the three-line fix, and the two things the fix breaks that nobody mentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;The usual framing is "nginx caches DNS". That's true but it undersells it. With a literal hostname:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/importer/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://importer:8001/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx resolves &lt;code&gt;importer&lt;/code&gt; &lt;strong&gt;once, while parsing the config&lt;/strong&gt;, and bakes the address into the running configuration. Two consequences follow, and the second is the dangerous one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If the container restarts and Docker gives it a new IP, nginx keeps sending traffic to the old one until you reload.&lt;/li&gt;
&lt;li&gt;If the name doesn't resolve &lt;em&gt;at parse time&lt;/em&gt;, the config is invalid. nginx exits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point 2 makes the blast radius total. Every upstream becomes a startup dependency of every route. Restart your reverse proxy at the wrong moment — during a deploy, after a host reboot when containers come up in an unlucky order — and it will refuse to come back because of a service nothing on the critical path needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Put the address in a variable and give nginx a resolver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;resolver&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="s"&gt;.0.11&lt;/span&gt; &lt;span class="s"&gt;valid=10s&lt;/span&gt; &lt;span class="s"&gt;ipv6=off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;resolver_timeout&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$svc_importer&lt;/span&gt; &lt;span class="s"&gt;http://importer:8001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$svc_api&lt;/span&gt;      &lt;span class="s"&gt;http://api:8000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$svc_tiles&lt;/span&gt;    &lt;span class="s"&gt;http://tiles:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/importer/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="nv"&gt;$svc_importer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;proxy_pass&lt;/code&gt; contains a variable, nginx defers resolution to request time. Now a missing container is a &lt;strong&gt;502 on its own route&lt;/strong&gt;, which is what you wanted all along. Everything else keeps serving.&lt;/p&gt;

&lt;p&gt;Two details in that resolver line:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;127.0.0.11&lt;/code&gt; is Docker's embedded DNS server, present inside every container on a user-defined network. On another platform it's whatever your service discovery exposes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ipv6=off&lt;/code&gt; is not optional decoration. Without it nginx asks for AAAA records too, and if it gets one for a service listening only on IPv4, the connection fails in a way that looks nothing like a DNS problem. Turn it off unless your services actually speak IPv6.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;valid=10s&lt;/code&gt; caps how long a resolved address is reused. Short enough that a container restart heals on its own; long enough that you're not doing a DNS lookup per request.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fix breaks, part one: the trailing slash
&lt;/h2&gt;

&lt;p&gt;This is where it stops being a three-line change.&lt;/p&gt;

&lt;p&gt;These two are not the same directive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://tiles:3000/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# note the trailing slash&lt;/span&gt;
&lt;span class="k"&gt;proxy_pass&lt;/span&gt; &lt;span class="nv"&gt;$svc_tiles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;# variable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a literal URI and a trailing slash, nginx &lt;strong&gt;strips the matched location prefix&lt;/strong&gt; before forwarding. &lt;code&gt;/tiles/catalog&lt;/code&gt; arrives upstream as &lt;code&gt;/catalog&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With a variable, nginx cannot do that — it doesn't know at parse time what the URI part is — so it forwards the full original path. &lt;code&gt;/tiles/catalog&lt;/code&gt; arrives upstream as &lt;code&gt;/tiles/catalog&lt;/code&gt;, and your tile server returns 404 for everything.&lt;/p&gt;

&lt;p&gt;You have to do the stripping yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/tiles/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/tiles/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="nv"&gt;$svc_tiles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing warns you. The config is valid, nginx starts, and the route 404s.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fix breaks, part two: &lt;code&gt;if&lt;/code&gt; below &lt;code&gt;rewrite&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;And now the second-order one, which cost me considerably more time.&lt;/p&gt;

&lt;p&gt;CORS preflight is normally handled with a short-circuit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/tiles/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/tiles/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'OPTIONS')&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;'Access-Control-Allow-Origin'&lt;/span&gt; &lt;span class="s"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;'Access-Control-Allow-Methods'&lt;/span&gt; &lt;span class="s"&gt;'GET,&lt;/span&gt; &lt;span class="s"&gt;POST,&lt;/span&gt; &lt;span class="s"&gt;OPTIONS'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="nv"&gt;$svc_tiles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;if&lt;/code&gt; never runs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rewrite&lt;/code&gt; and &lt;code&gt;if&lt;/code&gt; both belong to &lt;code&gt;ngx_http_rewrite_module&lt;/code&gt;, which evaluates its directives in order — and the &lt;code&gt;break&lt;/code&gt; flag &lt;strong&gt;stops that evaluation for the rest of the block.&lt;/strong&gt; Every rewrite-module directive after it, &lt;code&gt;if&lt;/code&gt; included, is skipped.&lt;/p&gt;

&lt;p&gt;So the preflight falls through to &lt;code&gt;proxy_pass&lt;/code&gt;, the browser gets whatever the upstream says about &lt;code&gt;OPTIONS&lt;/code&gt;, and your CORS headers never appear. The failure shows up in a browser console as a CORS error, which sends you looking at &lt;code&gt;add_header&lt;/code&gt; and origins — the two things that were correct all along.&lt;/p&gt;

&lt;p&gt;The fix is ordering, not content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/tiles/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# OPTIONS must come ABOVE the rewrite: `rewrite ... break` halts&lt;/span&gt;
    &lt;span class="c1"&gt;# ngx_http_rewrite_module, and `if` is a directive of that same module.&lt;/span&gt;
    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'OPTIONS')&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;'Access-Control-Allow-Origin'&lt;/span&gt; &lt;span class="s"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/tiles/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="nv"&gt;$svc_tiles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that this ordering trap only appears &lt;em&gt;because&lt;/em&gt; of the earlier fix. With a literal &lt;code&gt;proxy_pass&lt;/code&gt; and a trailing slash, there is no &lt;code&gt;rewrite&lt;/code&gt;, so there is nothing to halt the module and the &lt;code&gt;if&lt;/code&gt; works wherever you put it. Fixing the startup dependency introduced the rewrite; the rewrite introduced the ordering constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test your reverse proxy with a service stopped.&lt;/strong&gt; Not a service returning errors — a service &lt;em&gt;not running&lt;/em&gt;. That's the state a host reboot produces, and it's the one that turns a single failure into a total one. It takes thirty seconds to check and it's the only way to find this before it finds you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A variable in &lt;code&gt;proxy_pass&lt;/code&gt; changes URI handling, not just resolution timing.&lt;/strong&gt; If you're converting an existing config, every &lt;code&gt;proxy_pass&lt;/code&gt; that ended in a slash needs a &lt;code&gt;rewrite&lt;/code&gt; to replace it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In nginx, module ordering beats block ordering.&lt;/strong&gt; Directives from the same module run as a sequence, and &lt;code&gt;break&lt;/code&gt; ends that sequence. &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;rewrite&lt;/code&gt;, &lt;code&gt;return&lt;/code&gt; and &lt;code&gt;set&lt;/code&gt; all belong to the rewrite module, so their relative order matters in a way that &lt;code&gt;add_header&lt;/code&gt; and &lt;code&gt;proxy_set_header&lt;/code&gt; don't.&lt;/p&gt;

&lt;p&gt;Three lines to fix. Two more to keep it working.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>docker</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One View Per Layer: Four Sharp Edges I Found in My Own Code</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Mon, 24 Aug 2026 12:21:48 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/one-view-per-layer-four-sharp-edges-i-found-in-my-own-code-23e8</link>
      <guid>https://dev.to/srdjan_poppovic/one-view-per-layer-four-sharp-edges-i-found-in-my-own-code-23e8</guid>
      <description>&lt;p&gt;There is a layer in my database called &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Somebody created it, presumably by accident, and it sat there for months looking harmless. It was the only layer in the system that never served a single tile, and nobody noticed, because it was empty anyway.&lt;/p&gt;

&lt;p&gt;That layer turned out to be a symptom of a SQL injection vulnerability. This post is about the design that produced it — which I still think is a good design — and the four things I got wrong inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A web GIS with about 2.7 million features: 1.8 million points, 697,000 lines, 172,000 polygons. Users create layers through the UI, upload data into them, edit geometry, and expect to see it on a map.&lt;/p&gt;

&lt;p&gt;The features do &lt;strong&gt;not&lt;/strong&gt; live in a table per layer. They live in three tables — one for points, one for lines, one for polygons — with a &lt;code&gt;layer_id&lt;/code&gt; foreign key and a JSON column for attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project_pointfeature     1,820,288 rows
project_linefeature        697,009 rows
project_polygonfeature     171,830 rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a deliberate trade. A table per layer means DDL every time a user clicks "new layer", a migration story that never ends, and a schema that drifts. Three generic tables mean one schema, one set of indexes, and layers that are just rows in a metadata table.&lt;/p&gt;

&lt;p&gt;The cost lands on the tile server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://martin.maplibre.org/" rel="noopener noreferrer"&gt;Martin&lt;/a&gt; serves vector tiles from PostGIS. Point it at a database and it discovers spatial tables and views and publishes each as an MVT endpoint. It can be told to publish views but not tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;auto_publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;from_schemas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;public&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;publish_tables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;reload_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So: &lt;strong&gt;give every layer its own view.&lt;/strong&gt; A Django &lt;code&gt;post_save&lt;/code&gt; signal on the &lt;code&gt;Layer&lt;/code&gt; model creates it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;t19_saobracajni_znakovi&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feature_attrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;layer_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;layer_group_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;project_title&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;project_pointfeature&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
        &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;project_layer&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
        &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;project_layergroup&lt;/span&gt; &lt;span class="n"&gt;lg&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layer_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
        &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;project_project&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;81&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A user creates a layer in the browser. Five seconds later — one &lt;code&gt;reload_interval&lt;/code&gt; — it is a live tile endpoint. No migration, no deploy, no restart. There are 106 of these now.&lt;/p&gt;

&lt;p&gt;I still like this. Everything below is what it cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge 1: the layer named &lt;code&gt;1&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The signal built the view name like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;view_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE OR REPLACE VIEW &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;view_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; AS ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Layer.name&lt;/code&gt; is a &lt;code&gt;CharField&lt;/code&gt; filled in by users, with no validation on it. It goes straight into DDL through an f-string.&lt;/p&gt;

&lt;p&gt;A layer named &lt;code&gt;x" AS SELECT 1; DROP TABLE project_layer; --&lt;/code&gt; executes on save, with whatever privileges the application's database role happens to hold.&lt;/p&gt;

&lt;p&gt;I did not find this by thinking about attackers. I found it because of the layer called &lt;code&gt;1&lt;/code&gt;, whose view was missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR: syntax error at or near "1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unquoted identifier can't start with a digit. The exception was caught, logged, and swallowed, and that layer quietly had no tiles. &lt;strong&gt;A user-supplied string that breaks SQL syntax is the same string that could complete it.&lt;/strong&gt; The failure was the tell.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;psycopg2.sql&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;

&lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE OR REPLACE VIEW {view} AS ... WHERE f.layer_id = {lid}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view_name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;lid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rendered with a hostile name, the whole thing lands inside one quoted identifier with the quote doubled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="nv"&gt;"x&lt;/span&gt;&lt;span class="se"&gt;""&lt;/span&gt;&lt;span class="nv"&gt;_as_select_1;_drop_table_project_layer;_--"&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;"1"&lt;/code&gt; is now a perfectly legal view name, so that layer works too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge 2: quoting changes your names
&lt;/h2&gt;

&lt;p&gt;This is the part that would have caused a bad afternoon if I'd shipped the obvious fix.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;unquoted&lt;/strong&gt; identifier in Postgres is folded to lower case. A &lt;strong&gt;quoted&lt;/strong&gt; one is not. Every one of those 106 views was created unquoted, so they're all lower case in the catalog — while 23 layers have capital letters in their names.&lt;/p&gt;

&lt;p&gt;Switch naively to &lt;code&gt;sql.Identifier&lt;/code&gt; and &lt;code&gt;T53_Traffic_Cameras&lt;/code&gt; stops resolving to the existing &lt;code&gt;t53_traffic_cameras&lt;/code&gt; and creates a &lt;em&gt;second&lt;/em&gt; view beside it. The old one keeps existing. Martin keeps publishing both. Half your layers quietly fork.&lt;/p&gt;

&lt;p&gt;So the derivation has to reproduce what Postgres was doing implicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize_layer_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layer_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;layer_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Postgres folds only ASCII A-Z. str.lower() would also fold Cyrillic
&lt;/span&gt;    &lt;span class="c1"&gt;# and diverge from the names already in the catalog.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Z&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That ASCII-only detail matters here: four layers have Cyrillic names, and &lt;code&gt;str.lower()&lt;/code&gt; would have renamed them. The existing views were created by Postgres's rule, not Python's, and the two disagree outside ASCII.&lt;/p&gt;

&lt;p&gt;I verified it before merging by running the new function over every layer and diffing against the catalog: &lt;strong&gt;102 layers, 0 names changed.&lt;/strong&gt; That check took a minute and was the only thing standing between me and 23 orphaned views.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge 3: two code paths, two shapes
&lt;/h2&gt;

&lt;p&gt;There were two places that created these views: the signal, and a management command for bulk regeneration. Over time they diverged. The command included symbology columns for point styling; the signal didn't.&lt;/p&gt;

&lt;p&gt;The database ended up holding two shapes of the same thing — 15 views with symbology columns, 24 identical point layers without.&lt;/p&gt;

&lt;p&gt;That's not just untidy, because of a rule worth memorising:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;CREATE OR REPLACE VIEW&lt;/code&gt; can &lt;strong&gt;add&lt;/strong&gt; columns at the end. It cannot remove them, and it cannot reorder them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So saving a layer whose view had the "wrong" shape failed with &lt;code&gt;cannot drop columns from view&lt;/code&gt;. Caught, logged, swallowed — and the view silently kept its old definition. This had already caused a 500 on an unrelated endpoint, because the failed statement poisoned the caller's transaction.&lt;/p&gt;

&lt;p&gt;Two fixes. First, the column set is no longer a flag anyone can pass; it's derived from the geometry type, because only the point table &lt;em&gt;has&lt;/em&gt; a &lt;code&gt;symbology_id&lt;/code&gt; column — the command had been passing &lt;code&gt;with_symbology=True&lt;/code&gt; for lines and polygons too, where it could only ever have failed.&lt;/p&gt;

&lt;p&gt;Second, and this is the useful bit: &lt;strong&gt;the optional columns moved to the end of the SELECT list.&lt;/strong&gt; Because &lt;code&gt;CREATE OR REPLACE&lt;/code&gt; can append, 24 views could be brought into line with no interruption at all. Only the 15 that needed reordering required &lt;code&gt;DROP&lt;/code&gt; + &lt;code&gt;CREATE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;102 layers, 63 already aligned
 24 replaced in place   (no downtime)
 15 required DROP       (brief lock, inside a transaction)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Column &lt;em&gt;order&lt;/em&gt; is invisible to clients — MVT attributes are named. Choosing it deliberately turned most of a migration into a no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge 4: the view namespace is global, the layer namespace wasn't
&lt;/h2&gt;

&lt;p&gt;Layer names were unique per project. View names are unique per &lt;em&gt;database&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So two projects both had a layer called &lt;code&gt;tacke&lt;/code&gt;. Both mapped to one view. Whichever was saved last owned it, and the other layer served the wrong project's features — with no error anywhere, because from Postgres's point of view nothing was wrong.&lt;/p&gt;

&lt;p&gt;The tempting fix is to rename the views: &lt;code&gt;layer_81&lt;/code&gt; instead of &lt;code&gt;tacke&lt;/code&gt;. It's correct, and it's a coordinated deploy — tiles are requested by name, so every client has to change on the same day.&lt;/p&gt;

&lt;p&gt;The cheaper fix follows from noticing &lt;em&gt;why&lt;/em&gt; the frontend works at all: it builds the tile URL from the layer name it reads from the API. &lt;strong&gt;If layer names are unique, tile addresses are unique for free.&lt;/strong&gt; So the constraint belongs on the layer name, not on a new naming scheme:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;holder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;layer_holding_view_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tile_view_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                                     &lt;span class="n"&gt;exclude_pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;holder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Comparing the &lt;em&gt;derived&lt;/em&gt; name, so &lt;code&gt;Tacke&lt;/code&gt; and &lt;code&gt;tacke&lt;/code&gt; collide, and so do &lt;code&gt;centralne linije&lt;/code&gt; and &lt;code&gt;centralne_linije&lt;/code&gt;. One of the two existing duplicates was an empty layer in a test project; renaming it cost nothing, and the frontend followed automatically because it reads the name from the API rather than remembering it.&lt;/p&gt;

&lt;p&gt;While I was in there: renaming a layer created a view under the new name and left the old one behind, publishing a source no layer pointed at. A &lt;code&gt;pre_save&lt;/code&gt; now remembers the previous name so &lt;code&gt;post_save&lt;/code&gt; can drop it — unless another layer is using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I build it this way again
&lt;/h2&gt;

&lt;p&gt;Yes, with the edges filed off.&lt;/p&gt;

&lt;p&gt;A generic feature table plus a view per layer gets you user-created layers that become tile endpoints in seconds, without DDL migrations or deploys, and Martin's &lt;code&gt;reload_interval&lt;/code&gt; does the discovery for free. For an internal tool where people create layers as part of their work, that's the right shape.&lt;/p&gt;

&lt;p&gt;But it means &lt;strong&gt;your users write DDL identifiers&lt;/strong&gt;, indirectly, by typing a name into a form. Once you accept that, four things follow, and I got all four wrong first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compose DDL with &lt;code&gt;sql.Identifier&lt;/code&gt;, never an f-string. The failure that reveals it may look like a syntax error, not an attack.&lt;/li&gt;
&lt;li&gt;If you're adding quoting to something that ran unquoted, reproduce the old folding exactly and diff every existing name before you ship.&lt;/li&gt;
&lt;li&gt;Put optional columns last, so &lt;code&gt;CREATE OR REPLACE&lt;/code&gt; can add them without a drop.&lt;/li&gt;
&lt;li&gt;Check whether your derived namespace is wider than the namespace you enforce uniqueness in. Ours was, by exactly one level.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The layer named &lt;code&gt;1&lt;/code&gt; serves tiles now. It's still empty.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>gis</category>
      <category>django</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Same Zone, Same Projection, 427 Metres Apart</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Sun, 23 Aug 2026 07:36:47 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/same-zone-same-projection-427-metres-apart-1k9a</link>
      <guid>https://dev.to/srdjan_poppovic/same-zone-same-projection-427-metres-apart-1k9a</guid>
      <description>&lt;p&gt;I was writing a blog post about the architecture of a system I built when I found a bug in it. This is that bug, and the much more interesting thing standing behind it.&lt;/p&gt;

&lt;p&gt;The system ingests mobile mapping surveys — LiDAR trajectories with spherical photos along them, driven along roads. The files arrive in whatever coordinate system the surveyor was working in, which in this part of the world is not a settled question. So the upload form asks the user to pick one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SOURCE_SRID_CHOICES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32634&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;UTM zone 34N (EPSG:32634)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;31277&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Gauss-Kruger zone 7 (EPSG:31277)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8686&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Gauss-Kruger zone 7 (EPSG:8686)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two codes, one label. That was deliberate — I knew there were two definitions floating around for the same zone and wanted users to be able to pick the one matching their source.&lt;/p&gt;

&lt;p&gt;I was wrong about what the second one was.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the database says
&lt;/h2&gt;

&lt;p&gt;You don't have to guess about an EPSG code. If you're running PostGIS, the definition is sitting in &lt;code&gt;spatial_ref_sys&lt;/code&gt; and you can just read it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;srid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proj4text&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;spatial_ref_sys&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;srid&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;31277&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8686&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;31277 | +proj=tmerc +lat_0=0 +lon_0=21 +k=0.9999 +x_0=7500000 +y_0=0
        +datum=hermannskogel +units=m +no_defs

 8686 | +proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=0
        +ellps=bessel +towgs84=476.08,125.947,417.81,... +units=m +no_defs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at &lt;code&gt;lon_0&lt;/code&gt;. One is on the 21st meridian, the other on the 15th. Look at &lt;code&gt;x_0&lt;/code&gt;: 7,500,000 versus 500,000.&lt;/p&gt;

&lt;p&gt;These are not two definitions of the same zone. &lt;code&gt;8686&lt;/code&gt; is &lt;strong&gt;MGI 1901 / Slovenia Grid&lt;/strong&gt; — a different country's national grid, six degrees west.&lt;/p&gt;

&lt;p&gt;How far wrong? Take a coordinate pair from Belgrade and push it through both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ST_AsText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_Transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_SetSRID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_MakePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7457000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4958000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;31277&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4326&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;-- POINT(20.456 44.765)   Belgrade. Correct.&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ST_AsText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_Transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_SetSRID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_MakePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7457000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4958000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;8686&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4326&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;-- POINT(76.635 25.100)   Rajasthan, India. About 5,400 km off.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody had used the option yet — every record in the table had the field null — so this cost nothing except my confidence. But it's in a dropdown, labelled invitingly, waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that mistake was easy to make
&lt;/h2&gt;

&lt;p&gt;Here's the part that turns a careless bug into something worth writing about.&lt;/p&gt;

&lt;p&gt;The modern EPSG codes for this family are the &lt;code&gt;MGI 1901 / Balkans zone N&lt;/code&gt; series. If you go looking for them in &lt;code&gt;spatial_ref_sys&lt;/code&gt;, you get this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;srid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;srtext&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;spatial_ref_sys&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;srtext&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%MGI 1901%Balkans%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3907  MGI 1901 / Balkans zone 5   lon_0=15
3908  MGI 1901 / Balkans zone 6   lon_0=18
3909  MGI 1901 / Balkans zone 7   lon_0=21
3910  MGI 1901 / Balkans zone 8   lon_0=24

8677  MGI 1901 / Balkans zone 5   lon_0=15
8678  MGI 1901 / Balkans zone 6   lon_0=18
8679  MGI 1901 / Balkans zone 8   lon_0=24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the second block again. 8677 is zone 5, 8678 is zone 6, and 8679 is &lt;strong&gt;zone 8&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;867x&lt;/code&gt; series skips zone 7. There is no &lt;code&gt;8679 = zone 7&lt;/code&gt; to find, because zone 7 already had &lt;code&gt;3909&lt;/code&gt; and the later batch didn't re-issue it. So anyone scanning that numeric range for "the modern zone 7 code" finds a gap where their pattern-matching brain expects a hit, and the nearby numbers are all real, plausible, MGI-1901-family codes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;8686&lt;/code&gt; is one of those nearby numbers. It is a real code. It belongs to the same datum family. It is in the same region of Europe. It is simply the wrong country.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The correct code for Gauss-Krüger zone 7 in this region is EPSG:3909.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that actually matters
&lt;/h2&gt;

&lt;p&gt;Fixing the label is a one-line change. The thing worth your attention is the &lt;em&gt;other&lt;/em&gt; pair in that dropdown, because it fails in a much more dangerous way.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;31277&lt;/code&gt; and &lt;code&gt;3909&lt;/code&gt; both describe Gauss-Krüger zone 7. Same central meridian, same scale factor, same false easting, same ellipsoid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3909   +proj=tmerc +lat_0=0 +lon_0=21 +k=0.9999 +x_0=7500000 +ellps=bessel
       +towgs84=682,-203,480,0,0,0,0

31277  +proj=tmerc +lat_0=0 +lon_0=21 +k=0.9999 +x_0=7500000
       +datum=hermannskogel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every projection parameter is identical. The only difference is how the datum reaches WGS 84: an explicit three-parameter shift in one, PROJ's built-in Hermannskogel datum in the other.&lt;/p&gt;

&lt;p&gt;Same input through both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3909   POINT(20.451342 44.765252)
31277  POINT(20.456732 44.765230)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- distance between them&lt;/span&gt;
&lt;span class="mi"&gt;426&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;73&lt;/span&gt; &lt;span class="n"&gt;metres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;427 metres.&lt;/strong&gt; From an identical-looking projection definition, differing only in a datum parameter you have to scroll sideways to notice.&lt;/p&gt;

&lt;p&gt;And here is why that is worse than the 5,400 km error: it looks fine.&lt;/p&gt;

&lt;p&gt;Five thousand kilometres announces itself. You load the layer, it isn't on the map, you find the problem in thirty seconds. Four hundred metres puts your data in the right country, the right city, the right neighbourhood. Zoom out to a national view and both versions are the same pixel. Everything renders. Nothing errors.&lt;/p&gt;

&lt;p&gt;It only becomes visible when someone zooms in far enough to notice that the road inventory is on the wrong side of the road — in a dataset whose entire value proposition is sub-metre positional accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there are two in the first place
&lt;/h2&gt;

&lt;p&gt;Briefly, because it explains why you can't just delete the old one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;31277&lt;/code&gt; is marked &lt;strong&gt;deprecated&lt;/strong&gt; in the EPSG registry. It's the older realization, and PROJ resolves its datum through Hermannskogel. &lt;code&gt;3909&lt;/code&gt; is the current code with an explicit shift to WGS 84.&lt;/p&gt;

&lt;p&gt;But deprecated doesn't mean unused. Decades of surveying in this region was done, stored, and delivered in the older definition, and files still arrive that way. If you only offer the modern code, you silently apply the wrong datum shift to legacy data — the same 427 m error, in the other direction.&lt;/p&gt;

&lt;p&gt;So both belong in the dropdown. What doesn't belong is giving them the same label.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;The codes stay. The labels stop lying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SOURCE_SRID_CHOICES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32634&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;UTM zone 34N — WGS 84 (EPSG:32634)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3909&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Gauss-Krüger zone 7 — MGI 1901 (EPSG:3909)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;31277&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Gauss-Krüger zone 7 — MGI/Hermannskogel, legacy (EPSG:31277)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;8686&lt;/code&gt; is gone. &lt;code&gt;3909&lt;/code&gt; replaces it. The two remaining zone 7 entries name their datum, because the datum is the entire difference between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read &lt;code&gt;spatial_ref_sys&lt;/code&gt; instead of trusting the label.&lt;/strong&gt; The definition is in your database. &lt;code&gt;SELECT proj4text&lt;/code&gt; takes five seconds and tells you the central meridian, the false easting, and the datum. Every mistake in this post was visible in that one column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compare &lt;code&gt;lon_0&lt;/code&gt; and &lt;code&gt;x_0&lt;/code&gt; first.&lt;/strong&gt; They're the parameters that produce catastrophic, obvious errors, so they're the cheapest to check. If those match and you still have two codes, your difference is in the datum — which is the expensive, quiet kind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a plausibility check on transformed coordinates.&lt;/strong&gt; One bounding box test — does the result land inside the country this data is supposed to be in — would have caught the Slovenia Grid mistake automatically, at upload time, before it ever reached a map. It would not have caught the 427 m one, which is the point: cheap checks catch the loud failures, and you need to know they don't catch the quiet ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never label two coordinate systems identically.&lt;/strong&gt; If the user has to choose between them, the label has to contain the thing that differs. "Gauss-Krüger zone 7" twice is not a choice, it's a coin flip with a 427 m stake.&lt;/p&gt;

&lt;p&gt;Gap-filled numbering, deprecated-but-still-in-use codes, and neighbouring countries sharing a datum family — none of these are anyone's fault. They're the residue of a century of national surveying, encoded into a flat integer namespace. The registry is doing its job. The label was doing mine, badly.&lt;/p&gt;

</description>
      <category>gis</category>
      <category>postgres</category>
      <category>geospatial</category>
      <category>datascience</category>
    </item>
    <item>
      <title>My Tile Cache Has No Invalidation, So I Set It to Zero</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Thu, 20 Aug 2026 19:30:13 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/my-tile-cache-has-no-invalidation-so-i-set-it-to-zero-11l0</link>
      <guid>https://dev.to/srdjan_poppovic/my-tile-cache-has-no-invalidation-so-i-set-it-to-zero-11l0</guid>
      <description>&lt;p&gt;Here is a line from the config of a vector tile server that has been in production for months, serving 2.7 million features to a web GIS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;cache_size_mb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a cache, deliberately disabled. Underneath it, a comment I wrote for whoever touches this next — probably me, having forgotten:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;MUST stay 0.&lt;/strong&gt; Martin's in-memory tile cache has no invalidation hook for underlying data changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is about how I ended up there, because "turn off the cache" is the kind of decision that looks lazy until you've watched the alternative fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the system is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://martin.maplibre.org/" rel="noopener noreferrer"&gt;Martin&lt;/a&gt; is a vector tile server written in Rust. Point it at PostGIS and it will discover your spatial tables and views and serve each one as an MVT endpoint. It is genuinely excellent, and none of what follows is a complaint about it.&lt;/p&gt;

&lt;p&gt;In my case it sits in front of a road-inventory database: 1.8 million point features, 697,000 lines, 172,000 polygons, spread across roughly a hundred layers. Users don't just look at that data. &lt;strong&gt;They edit it&lt;/strong&gt; — move a sign, redraw a kerb line, correct an attribute — and expect the map to show the change.&lt;/p&gt;

&lt;p&gt;That last sentence is the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default that looks free
&lt;/h2&gt;

&lt;p&gt;Martin's tile cache defaults to 512 MB, on. For most tile workloads that's an obvious win: your data is a static extract refreshed nightly, tiles are expensive to build, memory is cheap. Cache everything.&lt;/p&gt;

&lt;p&gt;I left it on initially for exactly that reasoning. Then a user reported that a geometry they had edited still looked wrong on the map.&lt;/p&gt;

&lt;p&gt;The natural suspects, in order: the browser cached the tile, nginx cached the tile, the edit never committed, or the edit landed in the wrong layer. I checked all four. The database had the new geometry. The layer view returned the new geometry. The API returned the new geometry. The map did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;The useful thing about a tile is that it's a file. You can fetch it and look at its size.&lt;/p&gt;

&lt;p&gt;So: pick a feature, note the tile that contains it, fetch the tile, record the byte count. Edit the feature's geometry so the shape visibly changes. Fetch the same tile again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before edit:  242 B
after edit:   242 B
30s later:    242 B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Byte-identical. Not "similar size" — identical. Meanwhile the database and the layer view were both returning the new shape the entire time.&lt;/p&gt;

&lt;p&gt;I waited. It stayed 242 B. I waited longer. Still 242 B. The tile was still 242 B when I restarted the container, at which point it immediately became the correct tile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cached tile is served unchanged for the lifetime of the process.&lt;/strong&gt; There is no TTL to wait out and no hook that notices the underlying rows moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting that looks like it would help, and doesn't
&lt;/h2&gt;

&lt;p&gt;There is a &lt;code&gt;reload_interval&lt;/code&gt; in the config, and when you are staring at a stale tile it reads like the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;reload_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It isn't. &lt;code&gt;reload_interval&lt;/code&gt; re-discovers the &lt;strong&gt;catalog&lt;/strong&gt; — which tables and views exist, what their geometry columns are, what SRID they carry. It's what makes a newly created layer show up as a tile source without a restart, which in a system where users create layers is genuinely valuable.&lt;/p&gt;

&lt;p&gt;It does not touch cached tiles. The catalog and the cache are different things, and only one of them refreshes.&lt;/p&gt;

&lt;p&gt;This is worth stating plainly because the two settings sit near each other in the config file and it is very easy to assume one covers the other. I assumed it for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "no invalidation" actually means
&lt;/h2&gt;

&lt;p&gt;It's tempting to file this as a missing feature. It isn't, really — it's a hard problem wearing a simple name.&lt;/p&gt;

&lt;p&gt;To invalidate correctly, the tile server would have to know that a row changed, work out which tiles at which zoom levels contained the old geometry &lt;em&gt;and&lt;/em&gt; which contain the new one, and evict all of them. That means the tile server needs a change feed from Postgres, plus geometry-to-tile-index math for both the before and after state, at every zoom level.&lt;/p&gt;

&lt;p&gt;That is a substantial amount of machinery for a server whose main job is to be a fast, boring translator between PostGIS and MVT. Most deployments don't need it, because most tile data doesn't change under the reader.&lt;/p&gt;

&lt;p&gt;Mine does. So the cache is the wrong tool for my workload, and the right move is to not use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs to turn off
&lt;/h2&gt;

&lt;p&gt;This is the part I want to be honest about, because "I disabled the cache and everything was fine" would be a suspiciously tidy ending.&lt;/p&gt;

&lt;p&gt;Every tile request now goes to Postgres. With &lt;code&gt;cache_size_mb: 0&lt;/code&gt;, Martin builds each tile with &lt;code&gt;ST_AsMVT&lt;/code&gt; on demand, on every request, through pgbouncer, against tables holding millions of rows.&lt;/p&gt;

&lt;p&gt;That is survivable for a specific reason: &lt;strong&gt;the data is not read by the public.&lt;/strong&gt; This is an internal tool with a bounded number of authenticated users working on a bounded number of projects. The requests-per-second ceiling is set by how fast a few dozen people can pan a map, not by the internet.&lt;/p&gt;

&lt;p&gt;If this were a public basemap, the answer would be completely different — you'd cache aggressively at the edge and accept that edits take minutes to appear, or you'd pre-render tiles and rebuild on write.&lt;/p&gt;

&lt;p&gt;So the real lesson isn't "disable your cache". It's that &lt;strong&gt;cache correctness is a property of your workload, not of your tile server&lt;/strong&gt;, and the default is tuned for the workload where readers vastly outnumber writers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replaced it
&lt;/h2&gt;

&lt;p&gt;I left something out above, and it changes the conclusion.&lt;/p&gt;

&lt;p&gt;Turning off Martin's cache did not leave the system uncached. One layer up, nginx caches the same tiles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;^/(.+)/(\d+)/(\d+)/(\d+)$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache&lt;/span&gt;       &lt;span class="s"&gt;martin_tiles_cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache_key&lt;/span&gt;   &lt;span class="nv"&gt;$scheme$host$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache_lock&lt;/span&gt;  &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache_valid&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five seconds.&lt;/p&gt;

&lt;p&gt;That number looks almost pointless until you think about what a map client does. Panning a map fires dozens of tile requests, many of them repeats, within a second or two. &lt;code&gt;proxy_cache_lock on&lt;/code&gt; collapses concurrent requests for the same tile into one upstream fetch. The five-second window absorbs exactly that burst and nothing more.&lt;/p&gt;

&lt;p&gt;So the real difference between the two caches was never "cached vs uncached". It was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Martin's internal cache&lt;/th&gt;
&lt;th&gt;nginx cache&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TTL&lt;/td&gt;
&lt;td&gt;none — process lifetime&lt;/td&gt;
&lt;td&gt;5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eviction&lt;/td&gt;
&lt;td&gt;memory pressure only&lt;/td&gt;
&lt;td&gt;expiry, and the cache dir is purgeable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Effect of an edit&lt;/td&gt;
&lt;td&gt;invisible until restart&lt;/td&gt;
&lt;td&gt;visible within 5s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A cache without a TTL is not a cache with a long TTL. It is a different thing.&lt;/strong&gt; The first is a promise that data never changes; the second is a bet that it changes slower than the window. Only one of those was true here.&lt;/p&gt;

&lt;p&gt;The lesson isn't "don't cache tiles that users edit". It's that the acceptable staleness window is a product decision — five seconds is fine, five minutes probably is too, and "until someone restarts a container" isn't a window at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other number in that file
&lt;/h2&gt;

&lt;p&gt;While I was in there, one more setting was wrong in the opposite direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;worker_processes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It had been 18. Martin, like a lot of servers, picks a worker count from the host's visible CPUs — and inside a container, the host's CPU count is not your CPU allowance. Eighteen workers were contending for two cores' worth of scheduling.&lt;/p&gt;

&lt;p&gt;More workers than cores does not add throughput once you're saturated; it adds context switching and queueing, and it shows up in tail latency rather than in the average. The p50 looks fine. The p99 is where users live.&lt;/p&gt;

&lt;p&gt;Setting the worker count to the actual CPU allowance is one of those changes that produces no visible improvement in a benchmark and a real one in how the application feels.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone setting this up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ask what your staleness window is, and whether the cache can honour it.&lt;/strong&gt; If users edit rows and expect to see the result, a cache with no TTL and no invalidation is not a performance optimisation — it's a correctness bug you configured on purpose. A cache with a short TTL, one layer out, gets you most of the benefit and bounds the damage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test invalidation by byte count.&lt;/strong&gt; Fetch a tile, change the data, fetch again, compare sizes. It takes two minutes and gives you a fact instead of a belief. I spent longer than that theorising about nginx.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read what your reload setting actually reloads.&lt;/strong&gt; Catalog discovery and cache eviction are different, and adjacent config keys imply a relationship that isn't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give containerised servers your CPU allowance, not the host's.&lt;/strong&gt; Anything that auto-detects &lt;code&gt;nproc&lt;/code&gt; inside a container is auto-detecting the wrong number.&lt;/p&gt;

&lt;p&gt;And write the reason in the config file. That comment is the only thing standing between this setting and someone — me, in a year, looking at a cache set to zero and thinking it must be a mistake.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>gis</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Eleven Terabytes and No Raster Database</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:53:56 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/eleven-terabytes-and-no-raster-database-3bll</link>
      <guid>https://dev.to/srdjan_poppovic/eleven-terabytes-and-no-raster-database-3bll</guid>
      <description>&lt;p&gt;DailyMeteo is about 11 TB of GeoTIFFs — 1 km daily temperature and precipitation for all land on Earth, back to 1961. There is no raster database anywhere in the system. No PostGIS raster tables, no tile server in the read path for point queries, no object store. A Django app reads files off a disk.&lt;/p&gt;

&lt;p&gt;That sounds like something we haven't gotten around to fixing. It isn't. Here's the reasoning, and the places where it bit us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two machines, one job each
&lt;/h2&gt;

&lt;p&gt;The archive is produced on one machine and served from another.&lt;/p&gt;

&lt;p&gt;The production machine is a big box — a terabyte of RAM, most of it in use — that does nothing but run the interpolation. Space-time kriging over a continent is not a workload you want sharing a server with anything user-facing, because it will happily consume every core for six hours and then a request times out for reasons no one will connect to weather.&lt;/p&gt;

&lt;p&gt;The serving machine runs the API, the frontend, the database, GeoServer, the workers, and the reverse proxy, all as a Docker Swarm stack.&lt;/p&gt;

&lt;p&gt;Between them, a &lt;code&gt;rsync&lt;/code&gt; that runs every 30 minutes. Two details in it are load-bearing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It pulls, it doesn't push.&lt;/strong&gt; The serving machine reaches into the production machine and takes what's missing. Nothing runs on the production box on our behalf except a remote &lt;code&gt;rsync&lt;/code&gt; under &lt;code&gt;nice -n 19 ionice -c3&lt;/code&gt;, with a bandwidth cap. If the transfer is slow, the interpolation doesn't care. A push would have put the scheduling decision on the machine whose whole job is to not be interrupted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It transfers incomplete dates.&lt;/strong&gt; It used to only take dates where all 24 rasters existed. The effect was that when one zone failed, the perfectly good rasters for the other five stayed on the production box and the API reported the date as empty. Missing rasters are missing either way; this way, the ones that exist are at least reachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the filename is the index
&lt;/h2&gt;

&lt;p&gt;A point query needs to answer: for this coordinate, this variable, and these 400 dates, which files do I open?&lt;/p&gt;

&lt;p&gt;With files on a disk and a naming convention, that's string formatting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{var}_day_{YYYYMMDD}_equi7{_early|_late|}.tif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Work out which continental zone the point falls in, build 400 paths, open the ones that exist. No index to keep in sync, no ingest step, no migration when a year of backfill lands, no second system that can disagree with the disk about what exists. The pipeline finishes writing a file and it is &lt;em&gt;served&lt;/em&gt;, with no further action.&lt;/p&gt;

&lt;p&gt;What that buys is worth being explicit about, because it's easy to reach for a database out of habit. There is no state anywhere that can drift from reality. A file is either on the disk or it isn't. When we needed to know exactly what we could serve, the answer was &lt;code&gt;os.scandir&lt;/code&gt; over 24 directories — about a second for 580,000 entries — and it was &lt;em&gt;the truth&lt;/em&gt;, not a cached projection of it.&lt;/p&gt;

&lt;p&gt;What it costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No query planner.&lt;/strong&gt; "Every date where the July mean exceeded X anywhere in Europe" is not a question this shape can answer. It answers "give me these pixels from these files" very well and nothing else at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directories with 24,000 files.&lt;/strong&gt; Fine on ext4, and &lt;code&gt;scandir&lt;/code&gt; doesn't care, but &lt;code&gt;ls&lt;/code&gt; in a terminal will make you wait and any tool that stats every entry becomes the bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The convention is the schema, and it's enforced by nothing.&lt;/strong&gt; One file written with a different suffix is a silent data bug, not a constraint violation. This is exactly how we ended up serving duplicate values for 3,624 days — a variant appeared that the read path's ranking didn't know about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Given the access pattern — read a handful of pixels from an arbitrary set of files, never scan or join — I'd make the same call again. The mitigation for the last point isn't a database. It's that every read path goes through one function that knows the convention, and that function is the only place the convention exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file format does the work a database would
&lt;/h2&gt;

&lt;p&gt;The reason reading raw files is fast enough is Cloud Optimized GeoTIFF.&lt;/p&gt;

&lt;p&gt;Each raster is &lt;code&gt;Int16&lt;/code&gt;, LZW-compressed, internally tiled in 512×512 blocks, with five levels of overviews baked in. A European tile is 8229 × 5588 pixels and 10.2 MB on disk.&lt;/p&gt;

&lt;p&gt;The tiling is the point. To read one pixel you read one 512×512 block, not 10 MB. To draw a zoomed-out map you read an overview level that's already there, instead of downsampling the full raster. The layout means a reader can seek to the bytes it needs, and everything downstream — the point query, GeoServer's mosaics, the polygon clip — gets that for free.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Int16&lt;/code&gt; instead of a float is a factor-of-two on 11 TB, which is worth roughly five and a half terabytes of disk. Temperature stored in tenths of a degree loses nothing anyone can measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a request actually does
&lt;/h2&gt;

&lt;p&gt;A point query is Django REST Framework, and the interesting parts are all about what &lt;em&gt;not&lt;/em&gt; to do.&lt;/p&gt;

&lt;p&gt;Requests are authenticated with an API key, priced in credits, and cached in Redis — a keyed lookup of an immutable historical value is the ideal cache entry, so the TTL is a week. Bulk exports don't happen in the request at all; they're Celery tasks that write a file and email a link, because "clip 20 years of daily rasters to this polygon" and "answer within an HTTP timeout" are incompatible requirements.&lt;/p&gt;

&lt;p&gt;The polygon pricing had a nice bug in it, and it's the kind that only shows up at the edges. Cost is computed from pixels times days. Pixels came from the mask of the clipped raster — except the mask counts pixels &lt;em&gt;inside the polygon&lt;/em&gt;, and a polygon over the ocean is full of nodata pixels that are inside it and contain nothing. Draw a box over the Atlantic and you'd be charged for a full raster's worth of nothing. The fix is one line — exclude nodata as well as masked — but the shape of the mistake is general: "how much data is here" and "how much of this rectangle is here" are different questions, and the second one is easier to compute, so it's the one you accidentally write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running R in someone else's browser
&lt;/h2&gt;

&lt;p&gt;The part of the system I find most interesting isn't on the server at all.&lt;/p&gt;

&lt;p&gt;There's an R environment on the site — a console, an editor, plots, tables — and it doesn't run R on our machines. It runs webR, R compiled to WebAssembly, inside the user's browser tab. Their code fetches from our API, computes locally, and renders locally. We never see what they ran.&lt;/p&gt;

&lt;p&gt;That was a deliberate call. The alternative is an RStudio-shaped service where users execute arbitrary R on our infrastructure, and that is a sandbox problem, a resource-limits problem, a queueing problem and a security problem, forever. Moving it into the browser makes all four somebody else's — specifically, the browser's, which is very good at exactly this.&lt;/p&gt;

&lt;p&gt;The bill comes as about 45 MB of WebAssembly and R packages, downloaded before anything runs. We warm the runtime up in the background as soon as the page loads, so by the time someone has typed a question the worker is usually alive. "Usually" is doing real work in that sentence, and the first visit on a slow connection is not a great experience.&lt;/p&gt;

&lt;p&gt;The second cost is subtler. webR's default communication channel uses &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, and browsers only expose that to pages that are &lt;em&gt;cross-origin isolated&lt;/em&gt; — which means serving two specific headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch is that &lt;code&gt;require-corp&lt;/code&gt; also blocks every cross-origin subresource that doesn't explicitly opt in. Turn it on site-wide and the map's vector tiles stop loading and half the images on the marketing pages disappear. So it's scoped to the R pages only, which means the R runtime lives on an island with different security semantics from the rest of the app, and every asset it needs has to be reachable from that island.&lt;/p&gt;

&lt;p&gt;It's worth the trouble for one reason: &lt;code&gt;SharedArrayBuffer&lt;/code&gt; is the only channel that supports interrupting a running computation. Without it, a runaway &lt;code&gt;while (TRUE)&lt;/code&gt; can only be stopped by destroying the worker and booting a fresh 45 MB one. With it, there's a stop button that actually stops.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two failures worth stealing
&lt;/h3&gt;

&lt;p&gt;Both of these cost more time than they should have, and both look like someone else's bug until you find them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nginx doesn't know what &lt;code&gt;.mjs&lt;/code&gt; is.&lt;/strong&gt; The bundled &lt;code&gt;mime.types&lt;/code&gt; maps &lt;code&gt;.js&lt;/code&gt; and stops there. Every ES module served straight off disk — including both worker entry points, the map's and R's — went out as &lt;code&gt;application/octet-stream&lt;/code&gt;, and the browser refused them: &lt;em&gt;"Strict MIME type checking is enforced for module scripts."&lt;/em&gt; Every other asset was fine, so the app looked healthy while two of its major features were dead.&lt;/p&gt;

&lt;p&gt;The fix is a line in the Dockerfile. The part worth copying is the second line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s|\(application/javascript[[:space:]]\+\)js;|\1js mjs;|'&lt;/span&gt; /etc/nginx/mime.types &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'js mjs;'&lt;/span&gt; /etc/nginx/mime.types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a future base image formats that entry differently, the &lt;code&gt;sed&lt;/code&gt; silently does nothing and we're back to a broken worker discovered by a user. The &lt;code&gt;grep&lt;/code&gt; turns that into a failed build. A patch applied by pattern-matching someone else's file should always be followed by an assertion that it took.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A URL nothing would accept.&lt;/strong&gt; The assistant generates R against a helper with this signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;get_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;time_scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;time&lt;/code&gt; takes a comma-separated list of dates, as an alternative to &lt;code&gt;from&lt;/code&gt;/&lt;code&gt;to&lt;/code&gt;. Asked for six years of daily data, the model enumerated every date into &lt;code&gt;time&lt;/code&gt;. That's 2,192 dates and a &lt;strong&gt;24,222-character URL&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;webR failed with &lt;code&gt;problem writing module_download template in internet module&lt;/code&gt;, which reads like a webR bug and sent me looking in the wrong place. It isn't. Our own API returns 414 above roughly 8,190 characters — Apache's default &lt;code&gt;LimitRequestLine&lt;/code&gt; — so the same generated code, copied into RStudio, would have failed too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;  700 dates ( 7,810 chars) → 400   (request accepted)
1,000 dates (11,110 chars) → 414 URI Too Long
2,192 dates (24,222 chars) → 414
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tempting fix is to teach the model not to do that. We fixed the helper instead. Before building any URL, &lt;code&gt;get_data&lt;/code&gt; now normalises &lt;code&gt;time&lt;/code&gt;: a contiguous run collapses into a single &lt;code&gt;from&lt;/code&gt;/&lt;code&gt;to&lt;/code&gt; request, and a list with gaps is split into batches that keep every URL under 2,000 characters. The failing case went from 24,222 characters to 165, in one request, returning exactly the same 2,192 rows as &lt;code&gt;from&lt;/code&gt;/&lt;code&gt;to&lt;/code&gt; would.&lt;/p&gt;

&lt;p&gt;The signature didn't change — it's the contract the model was trained against, and it's written into its system prompt. Only the body did.&lt;/p&gt;

&lt;p&gt;That distinction is the whole point. When a language model generates code against your helpers, the helpers are the place to be defensive, because they're the part you control and the part that doesn't need retraining. Prompt changes are a request. Helper changes are a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blind spot
&lt;/h2&gt;

&lt;p&gt;The last thing this architecture taught me is that it degrades quietly, everywhere.&lt;/p&gt;

&lt;p&gt;A missing raster isn't an error; the date is just thinner. A failed source isn't an error; the day is produced with five zones instead of six. A transfer that skips an incomplete date isn't an error; the API simply has nothing for it. Every one of those is a reasonable local decision, and stacked together they mean the system can be substantially broken while every component reports success.&lt;/p&gt;

&lt;p&gt;The number that finally made it visible was the difference between two dates: the most recent day with &lt;em&gt;any&lt;/em&gt; raster, and the most recent day with &lt;em&gt;all 24&lt;/em&gt;. The first was five days back, which is normal and looked fine. The second was six weeks back.&lt;/p&gt;

&lt;p&gt;Nothing was alerting, because nothing was subtracting.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;DailyMeteo is at &lt;a href="https://dailymeteo.com" rel="noopener noreferrer"&gt;dailymeteo.com&lt;/a&gt;. The previous post covers what the data is and why every day is computed three times.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gis</category>
      <category>django</category>
      <category>webassembly</category>
      <category>architecture</category>
    </item>
    <item>
      <title>We Publish the Data Before It's Correct. On Purpose.</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:52:13 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/we-publish-the-data-before-its-correct-on-purpose-4j13</link>
      <guid>https://dev.to/srdjan_poppovic/we-publish-the-data-before-its-correct-on-purpose-4j13</guid>
      <description>&lt;p&gt;Someone asks for yesterday's maximum temperature in Belgrade. We don't have it. We won't have it for another four days, and when we do have it, the number will be wrong — not badly wrong, but wrong enough that we'll replace it a month later, and replace it again a year after that.&lt;/p&gt;

&lt;p&gt;That's not a bug report. That's the design.&lt;/p&gt;

&lt;p&gt;I want to explain why, because it turns out the three-pass thing is the most consequential decision in the whole system, and every other piece — the API, the storage layout, the way a chart gets drawn — bends around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data actually is
&lt;/h2&gt;

&lt;p&gt;DailyMeteo is a gridded daily climate archive. Four variables — maximum, minimum and mean temperature, and precipitation — at 1 km resolution, for all land on Earth, every day since 1 January 1961.&lt;/p&gt;

&lt;p&gt;Some numbers, because the shape of the problem is mostly numbers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resolution&lt;/td&gt;
&lt;td&gt;1 km, daily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Period&lt;/td&gt;
&lt;td&gt;1961-01-01 → present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variables&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tmax&lt;/code&gt;, &lt;code&gt;tmin&lt;/code&gt;, &lt;code&gt;tmean&lt;/code&gt;, &lt;code&gt;prcp&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continental zones&lt;/td&gt;
&lt;td&gt;6, in the Equi7 projection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rasters per day&lt;/td&gt;
&lt;td&gt;24 (6 zones × 4 variables)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One European tile&lt;/td&gt;
&lt;td&gt;8229 × 5588 px, 10.2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Archive&lt;/td&gt;
&lt;td&gt;~11 TB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twenty-four rasters per day, roughly 24,000 days. That's around 580,000 GeoTIFFs, and it's why "just put it in a database" stopped being an option early.&lt;/p&gt;

&lt;p&gt;The projection deserves a note. Equi7 splits the land surface into seven continental zones, each with its own equidistant projection, so that a kilometre is actually a kilometre wherever you are. A single global grid can't do that — it either stretches at the poles or bunches at the equator. The cost is that "the world" is six separate rasters that don't share a coordinate system, and every query has to work out which zone the point falls in before it can read a pixel.&lt;/p&gt;

&lt;h2&gt;
  
  
  How one day gets made
&lt;/h2&gt;

&lt;p&gt;Weather stations don't cover the world. They cover the parts of the world that historically built weather stations, which is a very uneven map. Turning some thousands of point observations into a continuous 1 km surface is the actual work.&lt;/p&gt;

&lt;p&gt;The pipeline pulls station observations from several archives — OGIMET, GSOD, GHCN-D, ECA&amp;amp;D, MeteoManz — because none of them alone is complete, and each has a different idea of what "yesterday" means. On a normal day that's something like 18,000 stations queried for a single variable.&lt;/p&gt;

&lt;p&gt;Then, per day and per variable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fit a trend.&lt;/strong&gt; A linear model on three covariates: elevation from a DEM, topographic wetness index, and a geometric temperature trend — a deterministic function of latitude and day of year that carries most of the seasonal signal before any interpolation happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Take the residuals.&lt;/strong&gt; Observed minus trend, at each station.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Krige the residuals in space &lt;em&gt;and&lt;/em&gt; time.&lt;/strong&gt; Not just "what do the neighbouring stations say today" but "what did this neighbourhood say yesterday and the day before", which matters enormously when a station is missing for a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the two rasters back together.&lt;/strong&gt; Trend surface plus interpolated residual surface.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Precipitation gets a second pass, because rain isn't like temperature. Temperature always has a value; rain is mostly zero. So it's modelled twice — first whether it rained at all, then how much, given that it did. Interpolating rainfall directly gives you a light drizzle over an entire continent, which is both wrong and hard to notice.&lt;/p&gt;

&lt;p&gt;None of this is my invention. The method comes from &lt;a href="https://doi.org/10.1002/2013JD020803" rel="noopener noreferrer"&gt;Kilibarda et al. (2014)&lt;/a&gt;, where Milan Kilibarda and co-authors established spatio-temporal regression kriging for global daily temperature at 1 km, and the interpolation runs through the &lt;code&gt;meteo&lt;/code&gt; R package developed by Milan Kilibarda and Aleksandar Sekulić. What's ours is the part that turns it into something that runs every night and answers HTTP requests. It is not fast. A single day, all zones, all variables, takes hours on a machine with a terabyte of RAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the same day is computed three times
&lt;/h2&gt;

&lt;p&gt;Here's the tension. Station data arrives late, and it arrives incrementally.&lt;/p&gt;

&lt;p&gt;The archive that gives you a station's observation within a day or two is not the archive that eventually gives you the quality-controlled version. Some networks publish a preliminary value and revise it. Some publish nothing for weeks and then backfill a month at once. If you wait until the data is final, "today's weather" is a year old.&lt;/p&gt;

&lt;p&gt;So we don't wait. Every day gets computed three times:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;early&lt;/strong&gt; — about four days behind, from whatever stations have reported. Runs nightly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;late&lt;/strong&gt; — about four months behind, once the monthly archives have settled. Runs monthly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;final&lt;/strong&gt; — the following year, on the fully quality-controlled record. Runs yearly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each pass overwrites nothing. The three versions sit side by side on disk, distinguished by a suffix in the filename:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tmax_day_20200715_equi7_early.tif
tmax_day_20200715_equi7_late.tif
tmax_day_20200715_equi7.tif        # final, no suffix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API ranks them — final beats late beats early — and serves the best one that exists for the date you asked about. Right now that means anything before 2024 comes back final, and the last few weeks come back early. The transition is invisible in the response, which is either elegant or dishonest depending on your mood; I'll come back to that.&lt;/p&gt;

&lt;p&gt;The ratio is heavily in favour of finished data. In the European maximum-temperature series: 23,010 final rasters, 851 late, 585 early. Ninety-five percent of the archive is settled. It's the leading edge that churns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Three variants of every file, ranked at read time, is a rule that lives in exactly one function. Rules like that get broken by accident.&lt;/p&gt;

&lt;p&gt;We added the &lt;code&gt;late&lt;/code&gt; stage after &lt;code&gt;early&lt;/code&gt; and &lt;code&gt;final&lt;/code&gt; were already running. Somewhere in the code that assembles a time series for a point query, the ranking knew about two variants and the directory now contained three. The unknown file didn't lose the ranking — it wasn't in the ranking at all, so it came through as a separate, additional row.&lt;/p&gt;

&lt;p&gt;The result was a chart with two values for the same date. Not a crash, not an error, not a log line. Two dots where there should be one, on 3,624 days' worth of data, sitting there for however long it took someone to look closely at a chart.&lt;/p&gt;

&lt;p&gt;The fix is three lines. The lesson isn't about the three lines. It's that "prefer the best available version" is a &lt;em&gt;domain rule&lt;/em&gt;, and if it's implemented as an incidental sort somewhere in a view, it will drift the moment the domain gains a version. It belongs in one named function that every read path goes through, and adding a variant should be a change to that function and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can actually do with it
&lt;/h2&gt;

&lt;p&gt;The data is the product, but nobody wants an 11 TB tarball. What's on top of it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Point queries.&lt;/strong&gt; Click a location, get a series. The API takes a latitude, longitude, variable, and either a range or a list of dates, and returns timestamps and values. Daily, monthly or annual, aggregated or as long-term means over the two standard climate normals — 1961–1990 and 1991–2020.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /meteo/v2/pq/?var=tmax&amp;amp;agg_level=agg&amp;amp;time_scale=day
    &amp;amp;from=2020-01-01&amp;amp;to=2020-12-31&amp;amp;lat=44.81&amp;amp;lon=20.46&amp;amp;api_key=…
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Areas.&lt;/strong&gt; Draw a polygon, get the aggregate over it, or export the clipped rasters. Large exports are priced by area and period and delivered by email when they're ready, because a polygon over Asia for a decade is not a request you answer inside an HTTP timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-term means and anomalies.&lt;/strong&gt; The monthly and annual aggregates and the two climate normals are precomputed — roughly 750 GB of them — so "how does this July compare to the 1991–2020 average" is a lookup, not a computation over 30 years of daily rasters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R in the browser.&lt;/strong&gt; There's an R environment on the site that runs entirely client-side, compiled to WebAssembly. You write R, it fetches from the API and plots, and your code never touches our servers. There's an assistant next to it that turns a question in English into R code against the same helpers. That one has enough engineering in it to deserve its own post, which is the next one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddable charts,&lt;/strong&gt; because half the time what someone actually wants is a temperature curve on their own page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd rather be honest about
&lt;/h2&gt;

&lt;p&gt;Two things about this design are genuinely awkward and I don't have clean answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The seam is invisible.&lt;/strong&gt; A response doesn't tell you which pass produced each value. Ask for a series spanning 2023 to now and you get final data and early data in one array, with no marker. For a chart, fine. For a paper, not fine. Exposing the processing level per value is the obvious fix and it's the kind of obvious fix that stays on the list because nothing visibly breaks without it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Available" and "complete" aren't the same thing, and we conflated them.&lt;/strong&gt; A date is only fully covered when all 24 rasters exist. When one source fails — and sources fail; a station archive returned zero rows out of 18,000 for several days recently — the pipeline still produces the zones it can. So a date can have 22 of 24 rasters: present, queryable, and quietly missing two continents.&lt;/p&gt;

&lt;p&gt;We were tracking "latest date with any raster". By that measure everything looked five days behind, which is normal. By the measure that matters — latest date with all 24 — we were six weeks behind and nobody had noticed, because no counter anywhere was counting that. Now there's an internal page that shows both numbers side by side, and the gap between them is the number I look at first.&lt;/p&gt;

&lt;p&gt;That's the recurring shape of this whole system, honestly. The hard part was never the kriging. It's that a pipeline which degrades gracefully also fails quietly, and you have to go out of your way to build the thing that tells you it did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The interpolation method and the &lt;code&gt;meteo&lt;/code&gt; R package behind it are the work of Milan Kilibarda and Aleksandar Sekulić at the University of Belgrade, Faculty of Civil Engineering.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;DailyMeteo is at &lt;a href="https://dailymeteo.com" rel="noopener noreferrer"&gt;dailymeteo.com&lt;/a&gt;. Next post: how 11 TB of rasters get served without a raster database, and what happened when we tried to run R inside a browser tab.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>data</category>
      <category>gis</category>
      <category>api</category>
    </item>
    <item>
      <title>Our Documentation Was Lying. The Model Believed It.</title>
      <dc:creator>Srdjan Popovic</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:26:58 +0000</pubDate>
      <link>https://dev.to/srdjan_poppovic/our-documentation-was-lying-the-model-believed-it-6n</link>
      <guid>https://dev.to/srdjan_poppovic/our-documentation-was-lying-the-model-believed-it-6n</guid>
      <description>&lt;p&gt;There is a variable called &lt;code&gt;slp&lt;/code&gt; — sea-level pressure. Our API documentation lists it as available. Our error messages list it among the valid options. Our fine-tuned model, asked about air pressure over Belgrade, will happily write you fifteen lines of R to fetch it.&lt;/p&gt;

&lt;p&gt;The request comes back &lt;code&gt;400&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It has been coming back &lt;code&gt;400&lt;/code&gt; the whole time.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this is
&lt;/h2&gt;

&lt;p&gt;We run &lt;a href="https://dailymeteo.com" rel="noopener noreferrer"&gt;dailymeteo.com&lt;/a&gt; — a daily meteorological archive for Europe, gridded at 1 km, running from 1961 to roughly five days ago. There's a chat endpoint where you ask a question in plain language and get back R code that queries the archive and answers it. Behind that sits a fine-tuned GPT model, trained on a few hundred question-and-code pairs.&lt;/p&gt;

&lt;p&gt;Last week I set out to retrain it on a better dataset. I expected to spend the day on hyperparameters. I spent it finding out that the model had been taught things that were not true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The training data was built from the documentation
&lt;/h2&gt;

&lt;p&gt;This is the part worth stopping on, because I suspect it is extremely common.&lt;/p&gt;

&lt;p&gt;When you build a fine-tuning set for "write code against our API", the natural move is to sit down with the API documentation and write examples from it. That is what had happened. Each example carried a system prompt describing what the API does, a question, and the R code that answers it.&lt;/p&gt;

&lt;p&gt;The trouble is that documentation is a &lt;em&gt;claim&lt;/em&gt; about a system, not the system. And nobody had checked the claim in a while.&lt;/p&gt;

&lt;p&gt;So before touching anything, I did the boring thing: I called the service and wrote down what actually came back.&lt;/p&gt;

&lt;p&gt;Three of its claims were wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claim 1: &lt;code&gt;slp&lt;/code&gt; is an available variable
&lt;/h3&gt;

&lt;p&gt;The data exists. There are 21,916 daily rasters sitting on disk, covering 1961 to 2020, Europe only.&lt;/p&gt;

&lt;p&gt;The API will not serve any of them. The allow-list in the view is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;VARS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tmax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tmin&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tmean&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;prcp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the check &lt;code&gt;var not in VARS&lt;/code&gt; guards every entry point. On top of that, the continent mapping points at a newer data folder that never received the pressure rasters at all. Two separate reasons for the same &lt;code&gt;400&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The best detail: the error message you get back still lists &lt;code&gt;slp&lt;/code&gt; among the available variables. The message is older than the list.&lt;/p&gt;

&lt;p&gt;The model had been taught to ask for it. Two examples in the training set did exactly that. So on any question about pressure, the model produced confident, well-formed, non-functional code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claim 2: "data from 1960 to 2024"
&lt;/h3&gt;

&lt;p&gt;Both ends wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;1960&lt;/code&gt; returns &lt;em&gt;"There's no data for date range."&lt;/em&gt; The archive starts in 1961. And the far end isn't a year at all — the archive is kept near-real-time. Daily data run to about five days ago, monthly to the previous month, annual to the last complete year. Writing a fixed end year into a system prompt guarantees it will be wrong within twelve months, silently.&lt;/p&gt;

&lt;p&gt;While measuring this I found something genuinely useful: &lt;strong&gt;asking for a range outside the archive is not an error.&lt;/strong&gt; The API quietly clips.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ann  1901 → 1970   returns  1961 → 1970
ann  1961 → 2035   returns  1961 → 2025
mon  1961-01 → 2030-12   returns through 2026-07
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That changes what "correct" code looks like. Generated code doesn't need to know where the archive ends — it can ask wide and read the actual extent back out of the response. That's a pattern that never goes stale. Several of our training examples had been hardcoding an end year instead, which ages badly and quietly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claim 3: the timestamp format
&lt;/h3&gt;

&lt;p&gt;The docs said the returned timestamp is formatted like the input date. For aggregated data, true. For long-term means, not remotely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ann   "1961-1990"          "1991-2020"
mon   "05.1961-1990"       "05.1991-2020"
day   "25.07.1961-1990"    "25.07.1991-2020"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It carries the &lt;em&gt;climate period&lt;/em&gt;, not a date. Any code doing &lt;code&gt;substr(timestamp, 1, 4)&lt;/code&gt; to pull a year out gets nonsense. Some of ours did.&lt;/p&gt;

&lt;h3&gt;
  
  
  And the thing that wasn't documented at all
&lt;/h3&gt;

&lt;p&gt;Long-term means — the most semantically awkward corner of the API — had no description whatsoever. The rule, once measured, is simple: &lt;strong&gt;the year inside the date selects which climate period you get.&lt;/strong&gt; Pass &lt;code&gt;1961&lt;/code&gt;, get 1961–1990. Pass &lt;code&gt;1991&lt;/code&gt;, get 1991–2020. Omit the year, get both.&lt;/p&gt;

&lt;p&gt;Nobody had written that down. So across 23 calls in the training set, long-term means were invoked &lt;strong&gt;seven different ways&lt;/strong&gt;, two of them mutually contradictory. The model wasn't learning a convention. It was learning that there isn't one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subtler poison: examples whose answer the question doesn't determine
&lt;/h2&gt;

&lt;p&gt;This one I didn't expect, and it's the one I'd most like other people to check for.&lt;/p&gt;

&lt;p&gt;Consider this pair from the training set:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Question:&lt;/strong&gt; plotting temperatures during autumn (September 1 to November 30) in Prague&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt; &lt;code&gt;from = "1999-09-01", to = "1999-11-30"&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Why 1999? No reason. The question doesn't say. Whoever wrote the example picked a year.&lt;/p&gt;

&lt;p&gt;There were 28 examples like this. And here is why they matter beyond tidiness: &lt;strong&gt;the model cannot possibly predict the answer from the question.&lt;/strong&gt; No amount of training reduces the error on that example, because the target contains information the input doesn't.&lt;/p&gt;

&lt;p&gt;You can see it in the metrics. In the previous training run, the loss spikes that survived all the way to the final epoch — steps 223, 238, 249, 253, 260, still spiking at 0.46–0.66 while everything around them sat at 0.13 — were these. They're not a hyperparameter problem. They're irreducible.&lt;/p&gt;

&lt;p&gt;Worse, what the model &lt;em&gt;does&lt;/em&gt; learn from them is the behaviour: &lt;strong&gt;invent a year, say nothing.&lt;/strong&gt; In production that's a model quietly answering a different question than the one asked.&lt;/p&gt;

&lt;p&gt;The fix cost nothing and didn't change a single choice the code makes. We just made it say so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"No period was specified in the question - using 1961 to 2020.\n\n"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same year. Now the answer is determined by the question, plus a disclosure the model can actually learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;p&gt;Nine steps, but the shape is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure the API.&lt;/strong&gt; Every boundary verified by calling the service. The result is one Python file that every other script imports — a single source of truth that is not the documentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit against it.&lt;/strong&gt; A script that walks each example and flags what doesn't work, what wastes model capacity, and what's cosmetic. First run: 13 examples the API rejects, 28 inventing periods, and a training set split 135-to-62 between two different code formatting styles for identical tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix what's unambiguously wrong.&lt;/strong&gt; The 13 rejects, and 23 silent period choices turned into disclosed ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalize style.&lt;/strong&gt; Half the set broke calls across multiple lines, half kept them on one — same task, double the tokens. That split was costing model capacity on a question with no informational value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill the gaps.&lt;/strong&gt; There was not a single example covering questions the API &lt;em&gt;can't&lt;/em&gt; answer — wind, humidity, forecasts. The model had been improvising. Now it answers "I don't have that, here's what I do have" — still as runnable R, because the contract is that every answer executes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive the explanations from the code&lt;/strong&gt;, not by hand, so they stay in sync when the code changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split off a validation set.&lt;/strong&gt; 29 examples the model never sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preflight.&lt;/strong&gt; Refuse to upload if anything above regressed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Train, then choose.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The previous run had no validation set
&lt;/h2&gt;

&lt;p&gt;This is the single change that mattered most, and it's the cheapest one.&lt;/p&gt;

&lt;p&gt;The earlier job ran three epochs with no validation file. Its training loss fell nicely, from 0.38 to 0.16, and everyone was happy.&lt;/p&gt;

&lt;p&gt;Training loss falls whether the model is learning or memorising. Without a held-out set, those two are indistinguishable. You are looking at a number that goes down in both the good case and the bad case, and concluding things about it.&lt;/p&gt;

&lt;p&gt;With validation, this run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Epoch&lt;/th&gt;
&lt;th&gt;Training loss&lt;/th&gt;
&lt;th&gt;Validation loss&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0.368&lt;/td&gt;
&lt;td&gt;0.285&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0.133&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.131&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.071&lt;/td&gt;
&lt;td&gt;0.140&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0.038&lt;/td&gt;
&lt;td&gt;0.152&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Training loss keeps falling all the way to 0.038 — near-perfect reproduction of what it was shown. Validation bottoms out in epoch 2 and turns back up. Textbook. Invisible without the held-out set.&lt;/p&gt;

&lt;p&gt;One incidental finding: the platform's &lt;code&gt;auto&lt;/code&gt; hyperparameter selection picked a learning-rate multiplier of &lt;strong&gt;0.51&lt;/strong&gt; on the previous run and &lt;strong&gt;2.0&lt;/strong&gt; on this one. The only meaningful difference was that our examples got longer. If you rely on &lt;code&gt;auto&lt;/code&gt;, know that it can quadruple your learning rate because you added a paragraph to your system prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then loss and behaviour disagreed
&lt;/h2&gt;

&lt;p&gt;Validation loss says take epoch 2. So I built a second evaluation that asks a different question: &lt;em&gt;does the generated code actually work?&lt;/em&gt; Each checkpoint answers the 29 held-out questions, and we measure whether R parses it, whether the API arguments are valid, whether it invents packages that aren't installed, and whether it discloses the period it chose.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Checkpoint&lt;/th&gt;
&lt;th&gt;Validation loss&lt;/th&gt;
&lt;th&gt;Failures out of 29&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Epoch 2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.131&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epoch 3&lt;/td&gt;
&lt;td&gt;0.140&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epoch 4&lt;/td&gt;
&lt;td&gt;0.152&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exactly inverted.&lt;/p&gt;

&lt;p&gt;The explanation is that validation loss measures token-level similarity to a reference answer. It does not measure whether code runs. As training continues, the model drifts away from the reference answer's exact wording — which raises the loss — while the code stays correct and the behaviours we deliberately added keep consolidating.&lt;/p&gt;

&lt;p&gt;Epoch 2 has the best number and cannot do the thing we trained it to do: on three of four questions with no stated period, it silently picks a year.&lt;/p&gt;

&lt;p&gt;We shipped epoch 4. Had we trusted the loss, we'd have shipped the one that doesn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then I got the measurement wrong too
&lt;/h2&gt;

&lt;p&gt;Worth including because it's the mistake I'd most likely repeat.&lt;/p&gt;

&lt;p&gt;The behavioural evaluation gave epoch 4 a perfect score on disclosure: 4 out of 4. I reported 100%.&lt;/p&gt;

&lt;p&gt;It asked each question &lt;strong&gt;once&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Re-running the same question against the deployed model several times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;temperature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;    &lt;span class="n"&gt;discloses&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="n"&gt;temperature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;      &lt;span class="n"&gt;discloses&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real figure is somewhere around 50–75%, not 100%. The behaviour is genuinely learned and genuinely better than the 25% the previous model managed — but it is not reliable, and a single sample per question cannot tell you that. One sample measures what a model did once. It says nothing about what it does.&lt;/p&gt;

&lt;p&gt;(&lt;code&gt;temperature = 0&lt;/code&gt; also isn't deterministic without a seed. Three calls, three different answers.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone fine-tuning against their own API
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Your model inherits your documentation's lies.&lt;/strong&gt; If the training data was written from the docs, every stale claim in them is now a learned behaviour. Call the service and write down what comes back. It took an afternoon and found three errors in a prompt that had been in production for months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check whether the question determines the answer.&lt;/strong&gt; Any example where the target contains information absent from the input is teaching the model to make things up. It also shows up as loss spikes that never come down, which is a cheap way to find them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship a validation split, even a tiny one.&lt;/strong&gt; Twenty-nine examples were enough to reveal an overfitting turn that was completely invisible for three epochs previously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure whether the output works, not whether it matches.&lt;/strong&gt; Loss is a proxy. Parse rate, valid arguments, no hallucinated dependencies — those are the thing itself. When the two disagree, the proxy is not the one to trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sample more than once.&lt;/strong&gt; A percentage from one draw per question is not a measurement, it's an anecdote with a decimal point.&lt;/p&gt;

&lt;p&gt;The model was maybe a fifth of the work. The rest was going back and asking the system what it actually does — which, in hindsight, is what anyone should have done before writing the documentation the model learned from.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>api</category>
      <category>datascience</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
