<?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: Fuel Price Today</title>
    <description>The latest articles on DEV Community by Fuel Price Today (@fuelpricetoday).</description>
    <link>https://dev.to/fuelpricetoday</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%2F4126455%2Faccd7ef0-9e91-4982-af15-85dd18666112.png</url>
      <title>DEV Community: Fuel Price Today</title>
      <link>https://dev.to/fuelpricetoday</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fuelpricetoday"/>
    <language>en</language>
    <item>
      <title>Our seven-day freshness filter was measuring the wrong thing</title>
      <dc:creator>Fuel Price Today</dc:creator>
      <pubDate>Fri, 23 Oct 2026 07:05:55 +0000</pubDate>
      <link>https://dev.to/fuelpricetoday/our-seven-day-freshness-filter-was-measuring-the-wrong-thing-2lmh</link>
      <guid>https://dev.to/fuelpricetoday/our-seven-day-freshness-filter-was-measuring-the-wrong-thing-2lmh</guid>
      <description>&lt;p&gt;In the &lt;a href="https://dev.to/fuelpricetoday/ingesting-the-uk-fuel-finder-api-the-rules-that-arent-in-the-docs-jc8"&gt;last post&lt;/a&gt; about the UK's Fuel Finder feed, we wrote that &lt;code&gt;price_last_updated&lt;/code&gt; means "last changed", not "last confirmed", and that we therefore let only prices from the last seven days into our averages.&lt;/p&gt;

&lt;p&gt;The first half was right. The second half was a mistake, and it took six weeks of history to see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that kept shrinking
&lt;/h2&gt;

&lt;p&gt;Every average on &lt;a href="https://fuelpricetoday.co.uk" rel="noopener noreferrer"&gt;fuelpricetoday.co.uk&lt;/a&gt; carries the number of stations behind it. For unleaded, outside motorway services:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;Stations in the feed&lt;/th&gt;
&lt;th&gt;Changed in the last 7 days&lt;/th&gt;
&lt;th&gt;Changed in the last 30 days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;9 September&lt;/td&gt;
&lt;td&gt;7,681&lt;/td&gt;
&lt;td&gt;7,293&lt;/td&gt;
&lt;td&gt;7,529&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 October&lt;/td&gt;
&lt;td&gt;7,760&lt;/td&gt;
&lt;td&gt;3,515&lt;/td&gt;
&lt;td&gt;7,242&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing was wrong with the feed. Prices had simply settled. In early September prices were climbing fast and nearly every station repriced within the week. By late October more than half of them were holding a price for longer than seven days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that is a bias, not just a smaller sample
&lt;/h2&gt;

&lt;p&gt;The UK regulations require a forecourt to report a price change within 30 minutes. A price that has not changed for ten days is therefore not stale: it is the price on the pump. A seven-day window does not select fresh data, it selects &lt;strong&gt;stations that recently changed their price&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And those are not a random sample. On 19 October, the share of each brand's unleaded prices that had changed in the past week:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Brand&lt;/th&gt;
&lt;th&gt;Stations&lt;/th&gt;
&lt;th&gt;Changed in 7 days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sainsbury's&lt;/td&gt;
&lt;td&gt;316&lt;/td&gt;
&lt;td&gt;314&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asda&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;298&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Morrisons&lt;/td&gt;
&lt;td&gt;341&lt;/td&gt;
&lt;td&gt;244&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tesco&lt;/td&gt;
&lt;td&gt;516&lt;/td&gt;
&lt;td&gt;313&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BP&lt;/td&gt;
&lt;td&gt;997&lt;/td&gt;
&lt;td&gt;490&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Esso&lt;/td&gt;
&lt;td&gt;1,440&lt;/td&gt;
&lt;td&gt;609&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shell&lt;/td&gt;
&lt;td&gt;892&lt;/td&gt;
&lt;td&gt;321&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Supermarkets reprice constantly, oil company forecourts much less. A seven-day window kept almost every Sainsbury's and about a third of Shells, so the average drifted towards supermarket prices. On the same day, the unleaded prices that had changed within the week averaged 172.7p; those unchanged for 8 to 30 days averaged 174.8p.&lt;/p&gt;

&lt;p&gt;It also exaggerated the trend. The stations that just changed are the ones that just followed the market, so in a falling October the seven-day average ran ahead of the market:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;7-day window&lt;/th&gt;
&lt;th&gt;30-day window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;22 September&lt;/td&gt;
&lt;td&gt;172.9p&lt;/td&gt;
&lt;td&gt;172.7p&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 October&lt;/td&gt;
&lt;td&gt;172.8p&lt;/td&gt;
&lt;td&gt;173.9p&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 October&lt;/td&gt;
&lt;td&gt;172.6p&lt;/td&gt;
&lt;td&gt;173.7p&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 1.1p gap is small in absolute terms, but it is the size of a weekly move, which is exactly what a price tracker exists to report.&lt;/p&gt;

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

&lt;p&gt;One rule across the site: a price counts unless it has been unchanged for more than 30 days. That threshold is not new to us; it is where we already flagged a price as suspect, because a month without a single change suggests a station that has stopped reporting rather than one holding steady. Averages, rankings and the heat map now use it too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A price counts on `day` unless it has been unchanged for more than&lt;/span&gt;
&lt;span class="c1"&gt;// `window` days. price_last_updated dates the last change, not a confirmation.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;priceLastUpdated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceLastUpdated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceLastUpdated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nb"&gt;window&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;The station count behind the national average was 7,137 on 22 October instead of sliding towards 3,000, and its composition no longer depends on how calm the market is that week.&lt;/p&gt;

&lt;p&gt;Two things we kept:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Published figures were not rewritten.&lt;/strong&gt; Our weekly press bulletins up to mid-October used the seven-day rule. Each bulletin now records the window it was computed with and says so on the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The age stays visible.&lt;/strong&gt; Every price on the site still shows when it last changed. A price can count in an average and still be shown as "unchanged since 2 October".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;The mistake was treating a filter as neutral. Any rule that admits rows based on behaviour (here, "changed recently") selects for whoever behaves that way. The check that caught it was cheap: publish the denominator next to every average, and watch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;The full history, every price at every station each day since 7 September 2026, is free to download under the Open Government Licence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://fuelpricetoday.co.uk/data" rel="noopener noreferrer"&gt;fuelpricetoday.co.uk/data&lt;/a&gt;: daily UK and town averages, the station list, and one history file per month, refreshed every three hours.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/fuelpricetoday/uk-fuel-prices-history" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, closed months mirrored weekly with a Data Package descriptor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The official feed shows only today's price, so this history is the part nobody else gives away.&lt;/p&gt;

</description>
      <category>opendata</category>
      <category>datascience</category>
      <category>sqlite</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Ingesting the UK Fuel Finder API: the rules that aren't in the docs</title>
      <dc:creator>Fuel Price Today</dc:creator>
      <pubDate>Tue, 20 Oct 2026 20:30:07 +0000</pubDate>
      <link>https://dev.to/fuelpricetoday/ingesting-the-uk-fuel-finder-api-the-rules-that-arent-in-the-docs-jc8</link>
      <guid>https://dev.to/fuelpricetoday/ingesting-the-uk-fuel-finder-api-the-rules-that-arent-in-the-docs-jc8</guid>
      <description>&lt;p&gt;Since the Motor Fuel Price (Open Data) Regulations 2025 came in, every filling station in the UK has to publish its prices and report each change within 30 minutes. The government's Fuel Finder service collects all of that and exposes it through an API. We pull it to run a price comparison site, and most of what we learned was not in the documentation. Here it is, in the order we hit it.&lt;/p&gt;

&lt;h2&gt;
  
  
  It only answers from the UK
&lt;/h2&gt;

&lt;p&gt;The first call from a laptop in France returned 403. Same credentials from a London server: 200. The API is geo-restricted, so the ingestion job has to run on a UK host. If you are developing from abroad, a 403 is not an auth problem, and it is worth catching it separately so you don't waste an hour on your client secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;403: Fuel Finder only answers UK IP addresses&lt;/span&gt;&lt;span class="dl"&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;h2&gt;
  
  
  Auth is plain client credentials
&lt;/h2&gt;

&lt;p&gt;You POST your client id and secret to &lt;code&gt;/api/v1/oauth/generate_access_token&lt;/code&gt; and get a token that lasts an hour. There is also a refresh endpoint, but asking for a fresh token is simpler and costs one request per run. We cache it and renew it a minute before expiry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expire&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Paging is by batch number, 500 rows at a time
&lt;/h2&gt;

&lt;p&gt;Stations come from &lt;code&gt;/api/v1/pfs?batch-number=N&lt;/code&gt; and prices from &lt;code&gt;/api/v1/pfs/fuel-prices?batch-number=N&lt;/code&gt;. There is no total count, so you stop when a batch is empty or shorter than 500. The documented limit is 100 requests a minute and one request at a time, so the loop is sequential with a 700 ms pause, a hard ceiling on the number of batches in case something loops, and retries with backoff on 429 and 5xx only. A 401 or 403 is never going to fix itself on retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;price_last_updated&lt;/code&gt; means "last changed"
&lt;/h2&gt;

&lt;p&gt;Each price carries &lt;code&gt;price_last_updated&lt;/code&gt;. It is tempting to read it as "last confirmed", and it isn't. A station that has held 139.9p for three weeks shows a three-week-old timestamp even if it reported faithfully every day. So the age of a price tells you how long it has been stable, not whether it is still true. We show the date next to every price ("unchanged since 2 Oct") rather than hiding old ones, and only let prices from the last seven days into averages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The location fields need cleaning
&lt;/h2&gt;

&lt;p&gt;The text fields arrive in capitals. The country field is a free-for-all: in our first snapshot it held "ENGLAND", "England", "UNITED KINGDOM", "E", "UK" and several hundred nulls. Deriving the nation from the postcode area is far more reliable than trusting it.&lt;/p&gt;

&lt;p&gt;Postcodes are mostly clean, but some come without the space (&lt;code&gt;CW13HD&lt;/code&gt;). The inward code is always the last three characters, so splitting from the right fixes them all.&lt;/p&gt;

&lt;p&gt;Coordinates had a few swapped latitude and longitude pairs and one with the wrong sign on the longitude. We only correct a point if the corrected version lands inside a UK bounding box. Otherwise we leave it alone rather than guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Station identity is not "name + town"
&lt;/h2&gt;

&lt;p&gt;Two different stations can share a trading name and a town. Name plus postcode is unique in our data, so URLs use that, with the station's &lt;code&gt;node_id&lt;/code&gt; as a tiebreaker. The feed also sent a few duplicate station and fuel rows, which a primary key on &lt;code&gt;(node_id, fuel_type)&lt;/code&gt; absorbs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opening hours of 00:00 to 00:00
&lt;/h2&gt;

&lt;p&gt;Plenty of stations report 00:00 to 00:00 with &lt;code&gt;is_24_hours&lt;/code&gt; set to false. That is not "open all day"; it is "not filled in". We treat it as unknown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the whole run, not just each row
&lt;/h2&gt;

&lt;p&gt;Per row, a price outside 50p to 350p is dropped. But the bigger protection is a set of checks on the run as a whole before anything is published: at least 7,000 stations, enough E10 and standard diesel prices, almost no stations missing a postcode, almost none outside the UK box. If any check fails, the job refuses to write. The database is built in a temporary file and swapped in with an atomic rename, so a bad run leaves yesterday's data in place instead of an empty site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep your own history
&lt;/h2&gt;

&lt;p&gt;The API gives you today. If you want to say whether a price is high, you need yesterday too, so every run appends one row per station, fuel and day. After six weeks that history is the most useful thing we have.&lt;/p&gt;

&lt;p&gt;If you want to see what all this turns into, the result is &lt;a href="https://fuelpricetoday.co.uk/" rel="noopener noreferrer"&gt;live petrol and diesel prices at 8,000 UK stations&lt;/a&gt;, with the date of every price shown next to it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>opendata</category>
      <category>javascript</category>
      <category>sqlite</category>
    </item>
  </channel>
</rss>
