<?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: Radar Labs</title>
    <description>The latest articles on DEV Community by Radar Labs (@sdijjxcfs).</description>
    <link>https://dev.to/sdijjxcfs</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%2F4199040%2Ff204050b-fbb3-43b3-8579-1f23c8a9a262.png</url>
      <title>DEV Community: Radar Labs</title>
      <link>https://dev.to/sdijjxcfs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sdijjxcfs"/>
    <language>en</language>
    <item>
      <title>One company, one 75,500 dollar gap between role families</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Sun, 25 Oct 2026 23:18:47 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/one-company-one-500-gap-between-role-families-5h57</link>
      <guid>https://dev.to/sdijjxcfs/one-company-one-500-gap-between-role-families-5h57</guid>
      <description>&lt;p&gt;The company is one company. The pay is not one number.&lt;/p&gt;

&lt;p&gt;I keep a public dataset of job postings that actually state a salary range — right now 8,049 of them, deduplicated, from 266 employers' own hiring systems. Same dataset, one question: inside a single company, how far apart are the role families?&lt;/p&gt;

&lt;p&gt;The answer is $75,500 of median difference between the top and bottom of the range table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data and ML: $237,500 median.&lt;/strong&gt; &lt;strong&gt;Support and operations: $162,000.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything else sorts in between — engineering, product, sales, marketing, finance, design, people/recruiting, legal, security.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this gap is worth sitting with
&lt;/h2&gt;

&lt;p&gt;Three things make it more than trivia.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is bigger than the level gap.&lt;/strong&gt; I also grouped these postings by job-title seniority. Titles containing "senior" or "staff" have a median of $185,791; titles with no level word at all sit at $193,500. Seniority words, as written in job ads, barely move the number — often they do not even point the right way. Role family moves it by $75,500. If you are optimising your career for pay, the discipline you pick does more work than the adjective in front of your title.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is a range, not a promise.&lt;/strong&gt; These are posted ranges: base salary bands the employer publishes to comply with pay-transparency law. They are not total compensation. They exclude equity, bonus, and everything else that quietly decides whether two people with the same title actually earn the same money. A $75,500 posted gap may be smaller or larger once equity is counted — the dataset cannot tell you, and any article claiming otherwise is making it up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It compounds.&lt;/strong&gt; A gap that size, held over a decade of raises computed as percentages of base, is not a one-time difference. It is the thing that decides whether you can buy a house.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method, so you can distrust it properly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Source: employers' own ATS job boards (Greenhouse, Ashby, Lever, Workable, Recruitee and friends), fetched directly — not scraped from aggregators that republish stale listings.&lt;/li&gt;
&lt;li&gt;Filter: only postings that publish an explicit salary range. That is the hard constraint, and it is why the dataset is 8,049 and not 19,000.&lt;/li&gt;
&lt;li&gt;Deduplication across boards, currency normalisation, and a median computed per role family rather than an average of averages.&lt;/li&gt;
&lt;li&gt;Known holes: US-heavy, base-salary-only, no equity, no cost-of-living adjustment, and role-family labels come from title keywords, so border cases (a "Solutions Engineer" in support? in sales?) are a judgement call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do with it
&lt;/h2&gt;

&lt;p&gt;If you are choosing between two offers at the same company, ask for the band of the role family you would actually be in, not the band of the title on the offer letter. If you are negotiating, the role-family median is a better anchor than the seniority word, because it is the number the company's own compensation team uses.&lt;/p&gt;

&lt;p&gt;The dataset is open: &lt;a href="https://github.com/sjdfi0wjvosdju9/job-salary-open-data" rel="noopener noreferrer"&gt;salary-ranges-full.csv&lt;/a&gt;, one row per posting with employer, title, location, and the posted range. The role-family breakdown, with sample sizes printed on the page, is at &lt;a href="https://sjdfi0wjvosdju9.github.io/topics/role-pay.html" rel="noopener noreferrer"&gt;the role pay page&lt;/a&gt;. Take it, quote it, argue with it — that is what it is for.&lt;/p&gt;

</description>
      <category>data</category>
      <category>salary</category>
      <category>career</category>
    </item>
    <item>
      <title>Remote work is not one pay discount: what 8,049 published salary ranges show</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Sun, 25 Oct 2026 23:08:14 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/remote-work-is-not-one-pay-discount-what-8049-published-salary-ranges-show-5517</link>
      <guid>https://dev.to/sdijjxcfs/remote-work-is-not-one-pay-discount-what-8049-published-salary-ranges-show-5517</guid>
      <description>&lt;p&gt;"Remote work pays less" is the most repeated salary claim on the internet, and the data behind it is usually a single number that averages over a dozen different markets.&lt;/p&gt;

&lt;p&gt;We collect published salary ranges from companies' own job board APIs (Greenhouse, Ashby, Lever, Workable) and normalise them to annual USD. The current snapshot has &lt;strong&gt;8,049 postings that actually state a range&lt;/strong&gt;, from 197 companies. Only postings with a stated range are included — nothing is imputed — and every figure below carries its sample size, because a median over 88 postings is not a median over 5,795.&lt;/p&gt;

&lt;p&gt;Snapshot date: 2026-10-26.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline number, and why it misleads
&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;Postings&lt;/th&gt;
&lt;th&gt;Median midpoint&lt;/th&gt;
&lt;th&gt;p25&lt;/th&gt;
&lt;th&gt;p75&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remote&lt;/td&gt;
&lt;td&gt;2,254&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$190,781&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$145,317&lt;/td&gt;
&lt;td&gt;$235,250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On-site&lt;/td&gt;
&lt;td&gt;5,795&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$204,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$150,000&lt;/td&gt;
&lt;td&gt;$261,250&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Remote roles are 6.5% below office roles at the median. That is the number most people quote, and it is a blend of functions where remote workers are paid &lt;em&gt;more&lt;/em&gt; and functions where they are paid a lot less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split by function, the gap inverts
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role family&lt;/th&gt;
&lt;th&gt;Remote n&lt;/th&gt;
&lt;th&gt;Remote median&lt;/th&gt;
&lt;th&gt;Office n&lt;/th&gt;
&lt;th&gt;Office median&lt;/th&gt;
&lt;th&gt;Difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;$216,902&lt;/td&gt;
&lt;td&gt;231&lt;/td&gt;
&lt;td&gt;$197,500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+9.8%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sales&lt;/td&gt;
&lt;td&gt;340&lt;/td&gt;
&lt;td&gt;$175,000&lt;/td&gt;
&lt;td&gt;899&lt;/td&gt;
&lt;td&gt;$159,950&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+9.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product&lt;/td&gt;
&lt;td&gt;166&lt;/td&gt;
&lt;td&gt;$222,465&lt;/td&gt;
&lt;td&gt;382&lt;/td&gt;
&lt;td&gt;$225,425&lt;/td&gt;
&lt;td&gt;−1.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marketing&lt;/td&gt;
&lt;td&gt;137&lt;/td&gt;
&lt;td&gt;$180,000&lt;/td&gt;
&lt;td&gt;382&lt;/td&gt;
&lt;td&gt;$193,675&lt;/td&gt;
&lt;td&gt;−7.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engineering&lt;/td&gt;
&lt;td&gt;561&lt;/td&gt;
&lt;td&gt;$212,000&lt;/td&gt;
&lt;td&gt;1,640&lt;/td&gt;
&lt;td&gt;$226,000&lt;/td&gt;
&lt;td&gt;−6.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Support / ops&lt;/td&gt;
&lt;td&gt;385&lt;/td&gt;
&lt;td&gt;$153,318&lt;/td&gt;
&lt;td&gt;830&lt;/td&gt;
&lt;td&gt;$168,301&lt;/td&gt;
&lt;td&gt;−8.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Finance / legal&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;td&gt;$181,022&lt;/td&gt;
&lt;td&gt;548&lt;/td&gt;
&lt;td&gt;$200,975&lt;/td&gt;
&lt;td&gt;−9.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data / ML&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;$206,000&lt;/td&gt;
&lt;td&gt;278&lt;/td&gt;
&lt;td&gt;$250,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−17.6%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two families pay a &lt;strong&gt;remote premium&lt;/strong&gt;: security (+9.8%) and sales (+9.4%). Both are functions where the candidate pool is thin and geography matters least — an enterprise seller or a security engineer can be sourced anywhere, so companies compete on the number rather than the office.&lt;/p&gt;

&lt;p&gt;Data and ML shows the steepest discount (−17.6%). Those postings cluster in a few expensive metros, and the remote-labelled version of the same role is often a level below the on-site one. That is not remote work being punished; it is two different levels carrying one label.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the dataset says the same thing about labels
&lt;/h2&gt;

&lt;p&gt;Two other results from the same snapshot, both of which look like bugs and are not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postings whose title contains &lt;strong&gt;"senior"&lt;/strong&gt; have a &lt;em&gt;lower&lt;/em&gt; median ($185,791) than postings with no level word at all ($193,500). Unlabelled titles are full of "Member of Technical Staff" and "Forward Deployed Engineer" at labs where no adjective means a senior band by default.&lt;/li&gt;
&lt;li&gt;Postings that list &lt;strong&gt;several cities&lt;/strong&gt; have a median of $345,000 against $195,000 for single-location postings. Multi-metro listings are a marker of roles the company has struggled to fill, not a flexibility bonus.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to use this
&lt;/h2&gt;

&lt;p&gt;If you are negotiating, benchmark against &lt;strong&gt;your role family&lt;/strong&gt;, not the headline. "Remote pays 6.5% less" is true of the dataset average and false for security and sales in this snapshot. And ask which level the band was written for — that explains more variance than the remote flag does.&lt;/p&gt;

&lt;p&gt;Method, sample sizes and the raw CSV are public:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deep dive with the full tables: &lt;a href="https://sjdfi0wjvosdju9.github.io/topics/remote-pay.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/topics/remote-pay.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Dataset (CC BY 4.0, no signup): &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/salary-data.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;What changed and when: &lt;a href="https://sjdfi0wjvosdju9.github.io/changes.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/changes.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want a specific cut — a role family, a city, a company — say so in the comments and I will publish it from the next refresh.&lt;/p&gt;

</description>
      <category>remote</category>
      <category>career</category>
      <category>salary</category>
      <category>data</category>
    </item>
    <item>
      <title>I counted 1,726 job postings as 'other'. My classifier was lying.</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Sat, 24 Oct 2026 02:46:16 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/i-counted-1726-job-postings-as-other-my-classifier-was-lying-cef</link>
      <guid>https://dev.to/sdijjxcfs/i-counted-1726-job-postings-as-other-my-classifier-was-lying-cef</guid>
      <description>&lt;p&gt;I counted 1,726 job postings as "other". My classifier was lying, and it moved a headline number.&lt;/p&gt;

&lt;p&gt;I publish a free dataset of job postings where the employer states a pay range. To make it more than a CSV, I group every posting into a role family — engineering, sales, product, design, support, and so on — so readers can ask "what does this company pay a data scientist" instead of "what is the average of everything".&lt;/p&gt;

&lt;p&gt;Last week the distribution looked wrong in a way that was easy to wave away:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;family&lt;/th&gt;
&lt;th&gt;postings&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;engineering&lt;/td&gt;
&lt;td&gt;3,188&lt;/td&gt;
&lt;td&gt;39.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;other&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,726&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sales&lt;/td&gt;
&lt;td&gt;868&lt;/td&gt;
&lt;td&gt;10.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;support / ops&lt;/td&gt;
&lt;td&gt;510&lt;/td&gt;
&lt;td&gt;6.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;data / ml&lt;/td&gt;
&lt;td&gt;488&lt;/td&gt;
&lt;td&gt;6.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One fifth of the corpus landing in "other" is not a labelling preference. It means the classifier did not know, and every per-family median I published was computed on a corpus with a hole in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually wrong
&lt;/h2&gt;

&lt;p&gt;The function was one flat list of rules, evaluated in order, with &lt;code&gt;engineering&lt;/code&gt; first:&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;RULES&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;engineering&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;engineer&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;developer&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;swe&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="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data / ml&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&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;ml&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;machine learning&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="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sales&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sales&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;account executive&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="bp"&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 things follow from that shape, and both are silent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;engineer&lt;/code&gt; wins before anything else can be considered.&lt;/strong&gt; &lt;code&gt;Sales Engineer&lt;/code&gt; → engineering. &lt;code&gt;Solutions Engineer&lt;/code&gt; → engineering. &lt;code&gt;Support Engineer&lt;/code&gt; → engineering. Three different jobs, one bucket, no error message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short, common substrings swallow rare ones.&lt;/strong&gt; &lt;code&gt;data&lt;/code&gt; is inside &lt;code&gt;Data Center Technician&lt;/code&gt;, so a rack-and-stack role became a data scientist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And &lt;code&gt;other&lt;/code&gt; was not "jobs we could not classify". It was &lt;strong&gt;jobs whose title did not contain any of my keywords&lt;/strong&gt; — technical program managers, executive assistants, people partners, compliance, fraud. A bucket of 1,726 postings that all mean something different, sitting in the same column.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is boring, and that is the point
&lt;/h2&gt;

&lt;p&gt;Four passes instead of one, ordered by specificity:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;disambiguation&lt;/strong&gt; — combinations that a single keyword gets wrong: &lt;code&gt;technical account manager&lt;/code&gt; and &lt;code&gt;customer success manager&lt;/code&gt; are support, not sales.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;specific rules&lt;/strong&gt; — multi-word, unambiguous: &lt;code&gt;account executive&lt;/code&gt;, &lt;code&gt;data scientist&lt;/code&gt;, &lt;code&gt;platform engineer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;generic rules&lt;/strong&gt; — single strong words: &lt;code&gt;engineer&lt;/code&gt;, &lt;code&gt;designer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;broad rules&lt;/strong&gt; — weak signals, tried last: &lt;code&gt;manager&lt;/code&gt;, &lt;code&gt;lead&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ordering is the whole mechanism. &lt;code&gt;engineer&lt;/code&gt; only runs after the specific pass has had its chance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test caught me, immediately
&lt;/h2&gt;

&lt;p&gt;I wrote 21 assertions before trusting the change, and the third one failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AssertionError: 'sales' != 'support / ops'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Technical Account Manager&lt;/code&gt; was still landing in sales, because &lt;code&gt;account manager&lt;/code&gt; sits in the sales rules and runs first. That is the same class of bug as the original one, one layer down. It is the reason the disambiguation pass exists at all — not because I thought of it, but because a test I wrote to describe my intent disagreed with my code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the numbers
&lt;/h2&gt;

&lt;p&gt;Same CSV, only the classifier re-run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;family&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;engineering&lt;/td&gt;
&lt;td&gt;3,188 (39.7%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,200 (27.4%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;td&gt;1,726 (21.5%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;680 (8.5%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sales&lt;/td&gt;
&lt;td&gt;868&lt;/td&gt;
&lt;td&gt;1,238&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;support / ops&lt;/td&gt;
&lt;td&gt;510&lt;/td&gt;
&lt;td&gt;1,219&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;finance / legal&lt;/td&gt;
&lt;td&gt;268&lt;/td&gt;
&lt;td&gt;728&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;product&lt;/td&gt;
&lt;td&gt;319&lt;/td&gt;
&lt;td&gt;547&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;other&lt;/code&gt; bucket did not shrink because I invented categories for it. It shrank because 1,200 postings had been sorted into the wrong family all along — mostly sales engineers, account managers, TPMs and support staff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I did not enjoy: a published claim moved
&lt;/h2&gt;

&lt;p&gt;Three weeks ago I published that, within the same role family, remote roles advertise about &lt;strong&gt;11.5% less&lt;/strong&gt; pay than on-site ones. That number was computed &lt;em&gt;on top of&lt;/em&gt; the broken classifier, with sales engineers counted as engineers.&lt;/p&gt;

&lt;p&gt;Re-run on the corrected families, it is &lt;strong&gt;−6.2%&lt;/strong&gt; (remote $212,000 vs on-site $226,000, n=563 vs 1,638).&lt;/p&gt;

&lt;p&gt;The direction survived; the size did not. I appended a dated revision note to the original article and wrote the change into the public change log, because a dataset site whose numbers silently drift is worse than one that never published them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three checks I now run before publishing any derived column
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does the residual bucket have a story?&lt;/strong&gt; If 20% of rows land in &lt;code&gt;other&lt;/code&gt;, that is not a category, it is a bug list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can two rules both claim the same row?&lt;/strong&gt; If yes, ordering is load-bearing, and ordering needs a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does a change in this column move a headline number?&lt;/strong&gt; If it can, the change needs a dated note on the article that used it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dataset is free and re-generated weekly: &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;salary-data.html&lt;/a&gt;, every number that moved is at &lt;a href="https://sjdfi0wjvosdju9.github.io/changes.html" rel="noopener noreferrer"&gt;changes.html&lt;/a&gt;, and the code that does the classification is in the same repo.&lt;/p&gt;

&lt;p&gt;If you spot a title in the &lt;code&gt;other&lt;/code&gt; bucket that clearly belongs somewhere, tell me which family — that is a one-line fix and a test case, and it is exactly the kind of thing that is easier from outside than from inside.&lt;/p&gt;

</description>
      <category>python</category>
      <category>data</category>
      <category>career</category>
      <category>opensource</category>
    </item>
    <item>
      <title>11 companies with 40+ open roles publish a salary on none of them</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Sat, 24 Oct 2026 00:25:25 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/11-companies-with-40-open-roles-publish-a-salary-on-none-of-them-295j</link>
      <guid>https://dev.to/sdijjxcfs/11-companies-with-40-open-roles-publish-a-salary-on-none-of-them-295j</guid>
      <description>&lt;h1&gt;
  
  
  11 companies with 40+ open roles publish a salary on none of them
&lt;/h1&gt;

&lt;p&gt;I measured pay transparency the way it is usually measured wrong.&lt;/p&gt;

&lt;p&gt;Most "salary transparency" numbers you see are computed over postings that &lt;em&gt;have&lt;/em&gt; a salary. That tells you nothing about how often companies disclose, because the ones that disclose nothing never enter the sample. So I built the denominator first: read &lt;strong&gt;every&lt;/strong&gt; posting from the public job feeds of 266 companies (18,668 after de-duplication), then counted how many of those carry a pay range.&lt;/p&gt;

&lt;p&gt;The result splits two ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same board, opposite behaviour
&lt;/h2&gt;

&lt;p&gt;Board-level rates hide the interesting part. Here is the range &lt;em&gt;within&lt;/em&gt; Ashby and Greenhouse, restricted to companies with at least 25 de-duplicated postings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;With pay / postings&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/sentry.html" rel="noopener noreferrer"&gt;sentry&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;42/42&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/rain.html" rel="noopener noreferrer"&gt;rain&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;37/37&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/algolia.html" rel="noopener noreferrer"&gt;algolia&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;greenhouse&lt;/td&gt;
&lt;td&gt;28/28&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/klaviyo.html" rel="noopener noreferrer"&gt;klaviyo&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;greenhouse&lt;/td&gt;
&lt;td&gt;150/154&lt;/td&gt;
&lt;td&gt;97.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/ramp.html" rel="noopener noreferrer"&gt;ramp&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;160/167&lt;/td&gt;
&lt;td&gt;95.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/plaid.html" rel="noopener noreferrer"&gt;plaid&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;129/135&lt;/td&gt;
&lt;td&gt;95.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the other end, still publishing &lt;em&gt;something&lt;/em&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;With pay / postings&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/supabase.html" rel="noopener noreferrer"&gt;supabase&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;1/59&lt;/td&gt;
&lt;td&gt;1.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/nubank.html" rel="noopener noreferrer"&gt;nubank&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;1/97&lt;/td&gt;
&lt;td&gt;1.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/sumup.html" rel="noopener noreferrer"&gt;sumup&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;greenhouse&lt;/td&gt;
&lt;td&gt;3/344&lt;/td&gt;
&lt;td&gt;0.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/elevenlabs.html" rel="noopener noreferrer"&gt;elevenlabs&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;1/161&lt;/td&gt;
&lt;td&gt;0.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/pay/gopuff.html" rel="noopener noreferrer"&gt;gopuff&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;lever&lt;/td&gt;
&lt;td&gt;1/784&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both lists contain companies that are healthy, well-funded and hiring hard. They simply made different decisions about the same field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eleven companies publish nothing at all
&lt;/h2&gt;

&lt;p&gt;40+ open postings, zero pay ranges, and this is not a scraping artefact — the field is absent from the feed, which I verified by dumping the raw JSON keys:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;Postings&lt;/th&gt;
&lt;th&gt;With pay&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;datadog&lt;/td&gt;
&lt;td&gt;greenhouse&lt;/td&gt;
&lt;td&gt;418&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;betterhelp&lt;/td&gt;
&lt;td&gt;greenhouse&lt;/td&gt;
&lt;td&gt;154&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;notion&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;140&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;langchain&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spotify&lt;/td&gt;
&lt;td&gt;lever&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;getyourguide&lt;/td&gt;
&lt;td&gt;greenhouse&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;trading212&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mollie&lt;/td&gt;
&lt;td&gt;ashby&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A board can be perfectly transparent and a company on it can be perfectly opaque. &lt;strong&gt;Board-level rates are a property of the company mix, not of the board.&lt;/strong&gt; That is the single most useful correction to come out of this dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the denominator discipline matters
&lt;/h2&gt;

&lt;p&gt;Two numbers from the same crawl:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postings that state a range: &lt;strong&gt;8,039&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Postings read: &lt;strong&gt;18,668&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you compute an average over the first number, you are describing the subset that already chose to be transparent. Any comparison built that way — "remote pays 12% less", "engineers at company X earn Y" — inherits the selection. The honest version pins the denominator to &lt;em&gt;everything read&lt;/em&gt;, which is why every rate in this post is &lt;code&gt;with pay / all postings&lt;/code&gt; and never &lt;code&gt;with pay / 8,039&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two failure modes I hit while building this, both of which produce plausible-looking numbers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mixing bases.&lt;/strong&gt; I first computed rates as &lt;code&gt;CSV rows / board postings&lt;/code&gt;, where the numerator was de-duplicated and the denominator was not. One company came out at 105.9% — a value that cannot exist, and which is the only reason I caught it. Same base on both sides, always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit words anywhere in the text.&lt;/strong&gt; A job description that mentions "hourly" &lt;em&gt;anywhere&lt;/em&gt; turned a $210,000 salary into $436,800,000. I now only read the 40 characters after the last number for a per-hour/per-month hint.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;The dataset is free and CC BY 4.0: &lt;strong&gt;8,039 postings that state a pay range&lt;/strong&gt;, from 266 companies across Greenhouse, Ashby, Lever and Workable, normalised to annual USD, with the original local-currency range kept alongside.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;Dataset page and CSVs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/data/publication-by-company.csv" rel="noopener noreferrer"&gt;Per-company pay rates as CSV&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sjdfi0wjvosdju9/job-salary-open-data" rel="noopener noreferrer"&gt;Raw repo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://sjdfi0wjvosdju9.github.io/how-we-choose.html" rel="noopener noreferrer"&gt;Method&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the same rows as JSON with salary filters and an incremental "only jobs I have not seen" mode, that runs as a hosted Actor at $0.0015 per row. The CSV above is the free version and always will be.&lt;/p&gt;

&lt;p&gt;If you take one thing from this: before you trust any salary statistic, ask what the denominator was. In pay transparency, the denominator is the whole story.&lt;/p&gt;

</description>
      <category>career</category>
      <category>salary</category>
      <category>data</category>
      <category>hiring</category>
    </item>
    <item>
      <title>My site answered 200 on all 163 URLs. Crawlers never saw a single page.</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Sat, 24 Oct 2026 00:10:04 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/my-site-answered-200-on-all-163-urls-crawlers-never-saw-a-single-page-23b0</link>
      <guid>https://dev.to/sdijjxcfs/my-site-answered-200-on-all-163-urls-crawlers-never-saw-a-single-page-23b0</guid>
      <description>&lt;h1&gt;
  
  
  My site answered 200 on all 163 URLs. Crawlers never saw a single page.
&lt;/h1&gt;

&lt;p&gt;For two months I ran a small data site: 163 URLs, 152 generated company pages, a 1.7 MB CSV, valid &lt;code&gt;sitemap.xml&lt;/code&gt;, and an IndexNow submission that returned &lt;code&gt;200&lt;/code&gt; every single week.&lt;/p&gt;

&lt;p&gt;Every check I had was green. And every search engine had &lt;strong&gt;zero&lt;/strong&gt; of my pages.&lt;/p&gt;

&lt;p&gt;This is the story of what was wrong, because I think it is a common failure and the symptom looks exactly like success.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clue was one request away
&lt;/h2&gt;

&lt;p&gt;On a hunch I fetched the same file twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.site/robots.txt | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;span class="c"&gt;# 26&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://example.site/robots.txt?probe=1"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;span class="c"&gt;# 162&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different files from the same path. The 26-byte one was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: *
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 162-byte one was mine, the one I had deployed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User-agent: *
Allow: /
Sitemap: https://example.site/sitemap.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host was rewriting the bare path. &lt;code&gt;?probe=1&lt;/code&gt; bypassed whatever was doing it, so every "is my robots.txt right?" check I had ever run against the bare URL was reading the host's stub, not my file. Crawlers read the bare path. So for two months I was politely asking every crawler in the world to go away, while my dashboard said the site was up.&lt;/p&gt;

&lt;p&gt;Three checks confirmed it was the platform, not a cache:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The injected response had no &lt;code&gt;charset&lt;/code&gt;, no &lt;code&gt;Cache-Control&lt;/code&gt;, no &lt;code&gt;ETag&lt;/code&gt;; mine had all three.&lt;/li&gt;
&lt;li&gt;Deploying the identical build to a &lt;strong&gt;brand-new subdomain&lt;/strong&gt; produced the same 26 bytes — so it was not a stale CDN object.&lt;/li&gt;
&lt;li&gt;The response headers of the injected copy came from the platform's edge, not from my deployment.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The second bug, in the same audit
&lt;/h2&gt;

&lt;p&gt;While auditing I submitted my sitemap to IndexNow and got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"errorCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"InvalidRequestParameters"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"One or more URLs are not related to your verified domain."&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;Seven URLs in a 163-URL sitemap still pointed at the &lt;strong&gt;old&lt;/strong&gt; hostname. The sitemap generator only rewrote the section it owned and left a hand-written header block from weeks earlier intact.&lt;/p&gt;

&lt;p&gt;And here is the embarrassing part: my own residue check had already reported "0 occurrences". I had written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;site\&lt;/span&gt;&lt;span class="o"&gt;*.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'old-host\.com'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-SimpleMatch&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;-SimpleMatch&lt;/code&gt; treats the pattern as a literal string — backslashes included. So I was searching for &lt;code&gt;old-host\.com&lt;/code&gt; with an actual backslash in it, which matches nothing, ever. The check was a no-op wearing a green badge.&lt;/p&gt;

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

&lt;p&gt;The real fix was not the migration. It was making the failure impossible to miss. Every deploy now runs a gate before publishing:&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;# robots.txt must not block the site, and the bare path must match ?probe=1
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?im)^\s*disallow:\s*/\s*$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots.txt blocks the whole site&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?im)^\s*allow:\s*/\s*$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no site-wide Allow: /&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;robots_url&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?probe=1&lt;/span&gt;&lt;span class="sh"&gt;"&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;fresh&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                        &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bare URL differs from ?probe=1 (crawlers read the stale one)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# sitemap must be single-host, and sampled pages must not be noindex
&lt;/span&gt;&lt;span class="n"&gt;hosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="mi"&gt;2&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;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;locs&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;://&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;u&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;hosts&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;site_host&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;                                &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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;foreign host in sitemap: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hosts&lt;/span&gt;&lt;span class="si"&gt;}&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;That gate is step 8 of 10 in the weekly pipeline now, and it is allowed to fail the run.&lt;/p&gt;

&lt;p&gt;I moved to a host whose &lt;code&gt;robots.txt&lt;/code&gt; I actually control end to end (a GitHub Pages &lt;em&gt;user&lt;/em&gt; site, served at the domain root — a project site under &lt;code&gt;/repo-name/&lt;/code&gt; would have broken every root-absolute link I have). Same bytes, different owner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s https://my-site/robots.txt
User-agent: *
Allow: /
Sitemap: https://my-site/sitemap.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Four things I would tell my past self
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;200 OK&lt;/code&gt; is not a statement about crawlability.&lt;/strong&gt; It is a statement about HTTP. Everything that decides whether a page enters an index — &lt;code&gt;robots.txt&lt;/code&gt;, &lt;code&gt;noindex&lt;/code&gt;, canonical tags, sitemap host consistency, whether the CDN serves a different body on the bare path — lives beside the status code, not in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Verify what machines read, not what your browser showed you.&lt;/strong&gt; A browser hides this class of bug completely, because you looked at &lt;code&gt;?probe=1&lt;/code&gt; when you debugged, and the crawler looks at &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Compare the bare URL with a query-string URL.&lt;/strong&gt; It costs one request and catches platform-injected responses, stale edge objects, and rewrite rules. I now do it on &lt;code&gt;robots.txt&lt;/code&gt; on every deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A dashboard that is green in every cell can hide a total failure.&lt;/strong&gt; My "163/163 URLs 200" metric was true and useless. The metric that would have caught this is "does the file a crawler fetches contain what I deployed".&lt;/p&gt;

&lt;h2&gt;
  
  
  The upside of finding it
&lt;/h2&gt;

&lt;p&gt;The audit also forced two fixes I would not have made otherwise: a sitemap that can never drift to a foreign host, and a &lt;code&gt;robots.txt&lt;/code&gt; with no BOM (a UTF-8 BOM in &lt;code&gt;robots.txt&lt;/code&gt; silently breaks the first directive, which is its own quiet version of this bug).&lt;/p&gt;

&lt;p&gt;The dataset I was trying to get indexed is public: &lt;strong&gt;8,040 job postings that state a salary, from 266 companies, normalised to annual USD&lt;/strong&gt; — &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;dataset page&lt;/a&gt;, &lt;a href="https://github.com/sjdfi0wjvosdju9/job-salary-open-data" rel="noopener noreferrer"&gt;raw CSV on GitHub&lt;/a&gt;, CC BY 4.0.&lt;/p&gt;

&lt;p&gt;If you are running a content site and your traffic is zero while every check is green, spend one request on &lt;code&gt;robots.txt?probe=1&lt;/code&gt;. It is the cheapest test in this post and it took me two months to run it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>1,170 job postings looked like they had no salary. My currency table was deleting them.</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Fri, 23 Oct 2026 21:47:01 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/1170-job-postings-looked-like-they-had-no-salary-my-currency-table-was-deleting-them-3ag4</link>
      <guid>https://dev.to/sdijjxcfs/1170-job-postings-looked-like-they-had-no-salary-my-currency-table-was-deleting-them-3ag4</guid>
      <description>&lt;p&gt;My dashboard had a line I almost shipped as a footnote: &lt;em&gt;0.1% of salary rows can't be converted to USD.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That reads like a rounding error. It wasn't. It was my filter deleting 1,170 real job postings.&lt;/p&gt;

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

&lt;p&gt;I run &lt;a href="https://apify.com/contributory_garnet_hyz/ats-job-feed" rel="noopener noreferrer"&gt;ATS Job Feed&lt;/a&gt;, which pulls open roles from Greenhouse, Lever, Ashby, Workable, Personio and Workday, normalises pay to yearly USD, and de-duplicates across boards. One of the switches, &lt;code&gt;salaryOnly&lt;/code&gt;, drops postings that don't state a number — people who only care about jobs with published pay.&lt;/p&gt;

&lt;p&gt;Under the hood the filter is one line:&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;if&lt;/span&gt; &lt;span class="n"&gt;salary_only&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;salaryLow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;salaryHigh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the conversion was a dict:&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;TO_USD&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;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GBP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.27&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CNY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;  &lt;span class="c1"&gt;# ten currencies
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything not in that dict returned &lt;code&gt;None&lt;/code&gt;. Which meant: a posting quoted in PLN, AED, MXN, RON, BRL or MYR got &lt;code&gt;salaryUsd* = None&lt;/code&gt;, and &lt;code&gt;salaryOnly&lt;/code&gt; then treated it as "no salary published" and dropped it.&lt;/p&gt;

&lt;p&gt;The posting was real. The pay was real. The number was on the page. My pipeline just couldn't convert it, and the filter read "can't convert" as "nothing to see".&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode worth remembering
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A missing conversion is indistinguishable from missing data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once a derived field feeds a filter, every failure of the derivation becomes a confident "no" instead of an honest "unknown". Nobody logs it, because &lt;code&gt;None&lt;/code&gt; is a legal value. My coverage stat even printed it as a tiny percentage instead of an error, because I'd written the stat to measure "rows without USD" — which is exactly the symptom, not the cause.&lt;/p&gt;

&lt;p&gt;If I had shipped that footnote, a customer filtering to salaried-only roles would have silently lost 1,170 postings in six currencies and had no way to notice. That's the bad kind of bug: not a crash, a quiet omission.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fetch a dated rate table once per run.&lt;/strong&gt; &lt;a href="https://open.er-api.com/v6/latest/USD" rel="noopener noreferrer"&gt;open.er-api.com&lt;/a&gt; needs no key, covers 166 currencies, and returns its own update timestamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache it for 24 hours&lt;/strong&gt; so 259 company fetches don't become 259 HTTP calls, and a network blip doesn't take out the run — on failure it falls back to a static table and logs a warning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invert honestly.&lt;/strong&gt; The API says "how much local currency per 1 USD". I need "what do I multiply by". That's &lt;code&gt;1 / rate&lt;/code&gt;, and it deserves its own test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never guess.&lt;/strong&gt; Unknown currency → &lt;code&gt;null&lt;/code&gt;, not 1.0, not the previous row's rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish the provenance.&lt;/strong&gt; Every run reports &lt;code&gt;fxDate&lt;/code&gt; in its summary: &lt;code&gt;Fri, 23 Oct 2026 00:02:31 +0000&lt;/code&gt;. A converted number you can't trace to a date is a rumour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the original numbers.&lt;/strong&gt; Output carries &lt;code&gt;salaryCurrency&lt;/code&gt; plus the local low/high, so a GBP posting shows both &lt;code&gt;375,000–640,000 GBP&lt;/code&gt; and &lt;code&gt;502,236–857,149 USD&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What it actually moved
&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;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;rows with a USD midpoint&lt;/td&gt;
&lt;td&gt;7,994&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8,004&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rows where USD midpoint is missing&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;non-USD rows that convert&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;777&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;salaryOnly&lt;/code&gt; pass-through, 259-company pool&lt;/td&gt;
&lt;td&gt;6,837&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;8,005&lt;/strong&gt; (+17%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sample: &lt;code&gt;anthropic&lt;/code&gt;, GBP &lt;code&gt;375,000–640,000&lt;/code&gt; → USD &lt;code&gt;502,236–857,149&lt;/code&gt;. Currency mix across the paid rows: USD 7,227 / EUR 405 / CAD 169 / GBP 155 / SGD 10 / MXN 9 / AUD 6 / INR 6 / AED 5 / CNY 4 / BRL 3 / PLN 2 / RON 2 / MYR 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three checks you can copy
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Split "unknown" from "no".&lt;/strong&gt; If a derived field gates a filter, log &lt;code&gt;derived is None&lt;/code&gt; separately from &lt;code&gt;field is absent&lt;/code&gt;. They look identical downstream and mean opposite things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make derivations self-describing.&lt;/strong&gt; Rate date, timezone, source version — print it in the artefact, not just in a log line you'll never read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the unknown path.&lt;/strong&gt; One test per branch: static rate works, live rate overrides it, unknown currency stays &lt;code&gt;null&lt;/code&gt;. That third test is the one that keeps the bug from coming back.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;The feed is on &lt;a href="https://apify.com/contributory_garnet_hyz/ats-job-feed" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; — $0.0015 per de-duplicated posting with a normalised annual salary, no charge for rows that get filtered out. The same data is free as a &lt;a href="https://sjdfi0wjvosdju9.github.io/data/salary-ranges-full.csv" rel="noopener noreferrer"&gt;CSV&lt;/a&gt; (8,004 rows, CC BY 4.0), and you can look up a single company on &lt;a href="https://sjdfi0wjvosdju9.github.io/lookup.html" rel="noopener noreferrer"&gt;this page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building something similar and you've got a filter sitting downstream of a conversion step — go count the &lt;code&gt;None&lt;/code&gt;s. They may not be as small as they look.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>data</category>
      <category>career</category>
    </item>
    <item>
      <title>I was deleting 17% of real job postings with one line of dedupe</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Fri, 23 Oct 2026 21:11:35 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/i-was-deleting-17-of-real-job-postings-with-one-line-of-dedupe-4002</link>
      <guid>https://dev.to/sdijjxcfs/i-was-deleting-17-of-real-job-postings-with-one-line-of-dedupe-4002</guid>
      <description>&lt;p&gt;A one-line dedupe rule was quietly deleting one in six real job postings from my dataset. Here is how it showed up, and the field I forgot to put in the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom was a number I was proud of
&lt;/h2&gt;

&lt;p&gt;After cleaning up a date bug, I looked at my pipeline's own funnel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;18,833 postings scraped  →  15,548 after dedupe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 17.4% duplicate rate. I had been treating that as proof the sources were messy and my dedupe was doing heroic work. It is not. On real job-board data, cross-source duplicates sit around 1–3%. Anything much above that is usually not the data being dirty — it is your key being too coarse.&lt;/p&gt;

&lt;p&gt;So I stopped celebrating and measured what was actually colliding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the collision
&lt;/h2&gt;

&lt;p&gt;I took seven companies (&lt;code&gt;stripe&lt;/code&gt;, &lt;code&gt;gitlab&lt;/code&gt;, &lt;code&gt;anthropic&lt;/code&gt;, &lt;code&gt;figma&lt;/code&gt;, &lt;code&gt;brex&lt;/code&gt;, &lt;code&gt;ramp&lt;/code&gt;, &lt;code&gt;openai&lt;/code&gt;), 2,835 postings, grouped them by my dedupe key, and then asked a much more useful question: &lt;strong&gt;inside each collision group, how many distinct locations are there?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The key was:&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;title_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&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;The answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;194 collision groups&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;188 of them (97%) contained more than one location&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;353 rows merged away, &lt;strong&gt;347 of which were distinct jobs&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Real examples, straight from the run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;Title&lt;/th&gt;
&lt;th&gt;Locations collapsed into one row&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;stripe&lt;/td&gt;
&lt;td&gt;abuse investigator&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Dublin&lt;/code&gt; and &lt;code&gt;Seattle, San Francisco, New York City&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stripe&lt;/td&gt;
&lt;td&gt;account executive commercial hunter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Chicago&lt;/code&gt; and &lt;code&gt;Singapore&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stripe&lt;/td&gt;
&lt;td&gt;account executive platforms hunter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Chicago&lt;/code&gt;, &lt;code&gt;Chicago, Illinois&lt;/code&gt;, &lt;code&gt;London&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those are not duplicates. They are separate requisitions, with separate links and usually separate pay ranges. My "clean" dataset was throwing them away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one field
&lt;/h2&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;location_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clean_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&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="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&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="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&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="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# US-Chicago == us chicago
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+&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="n"&gt;text&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="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;title_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nf"&gt;location_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&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;Two details that bit me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normalise the hyphen.&lt;/strong&gt; Without it, &lt;code&gt;US-Chicago&lt;/code&gt; and &lt;code&gt;us chicago&lt;/code&gt; are different keys. My first test failure was exactly this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the fingerprint too.&lt;/strong&gt; If you build "only return jobs I have not seen" on an id derived from company+title, a second city for the same title looks like an already-seen job and never gets delivered.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Title-only key&lt;/th&gt;
&lt;th&gt;With location&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7 companies: scraped → deduped&lt;/td&gt;
&lt;td&gt;2,835 → 2,482 (12.5% lost)&lt;/td&gt;
&lt;td&gt;2,835 → 2,824 (&lt;strong&gt;0.4%&lt;/strong&gt; lost)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full pool: scraped → deduped&lt;/td&gt;
&lt;td&gt;18,833 → 15,548 (17.4% lost)&lt;/td&gt;
&lt;td&gt;18,833 → &lt;strong&gt;18,567&lt;/strong&gt; (1.4% lost)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postings that state a salary&lt;/td&gt;
&lt;td&gt;6,833&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7,984 (+16.9%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The recovered rows are mostly mid-to-low pay, so the median moved down slightly: &lt;strong&gt;$204,750 → $200,000&lt;/strong&gt;. That is also a lesson — the bug was biasing the dataset &lt;em&gt;upward&lt;/em&gt;, because the rows it deleted were disproportionately the many-city, lower-level sales and support reqs. If you only ever look at the median, the bias is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I now follow
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Treat the dedupe rate as an instrument, not a trophy.&lt;/strong&gt; If it is above a few percent, print the collision groups and look at what differs inside them — location, department, employment type, seniority. The first differing field is almost always the one you forgot in the key.&lt;/p&gt;

&lt;p&gt;And the inverse check is just as useful: if your dedupe rate is &lt;em&gt;near zero&lt;/em&gt;, you are probably not deduplicating anything at all, and your cross-source duplicates are still in there.&lt;/p&gt;




&lt;p&gt;The corrected numbers are live at &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;sjdfi0wjvosdju9.github.io/salary-data.html&lt;/a&gt; — 18,567 postings, 7,984 with published pay, 259 companies, free CC BY 4.0 CSV. The age distribution is on &lt;a href="https://sjdfi0wjvosdju9.github.io/freshness.html" rel="noopener noreferrer"&gt;a separate page&lt;/a&gt; if you came here for the earlier date-field bug. If you would rather not maintain the pipeline, the same data runs as a paid Apify Actor — but read the free CSV first and decide whether it is worth $0.0015 a row.&lt;/p&gt;

</description>
      <category>data</category>
      <category>api</category>
      <category>python</category>
      <category>career</category>
    </item>
    <item>
      <title>0% of Greenhouse jobs were over 6 months old. That was my bug, not their data</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Fri, 23 Oct 2026 20:55:58 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/0-of-greenhouse-jobs-were-over-6-months-old-that-was-my-bug-not-their-data-40ee</link>
      <guid>https://dev.to/sdijjxcfs/0-of-greenhouse-jobs-were-over-6-months-old-that-was-my-bug-not-their-data-40ee</guid>
      <description>&lt;p&gt;I spent a week assuming my scraper was fine. Then a sanity check told me 0% of Greenhouse postings were older than six months, which is not a thing that happens on a real job board.&lt;/p&gt;

&lt;p&gt;The culprit was one field. And it is not exotic — if you have ever scraped a job feed, you have probably made the same mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake is &lt;code&gt;updated_at&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Our Greenhouse connector read:&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;postedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updated_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_at&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;That reads fine. It is wrong twice over. First, the Greenhouse board API does not return &lt;code&gt;created_at&lt;/code&gt; for a job, so the fallback never fires. Second — and this is the part that matters — &lt;code&gt;updated_at&lt;/code&gt; is &lt;em&gt;when a recruiter last touched the requisition&lt;/em&gt;, not when the job went live.&lt;/p&gt;

&lt;p&gt;Here is what that does to real data. &lt;code&gt;Account Executive, AI Native&lt;/code&gt; came back with a recent &lt;code&gt;updated_at&lt;/code&gt;. Its actual &lt;code&gt;first_published&lt;/code&gt; is &lt;strong&gt;2024-12-20&lt;/strong&gt;. A req opened in 2024 and edited yesterday looks one day old.&lt;/p&gt;

&lt;p&gt;The fix is one line:&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;postedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updated_at&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;first_published&lt;/code&gt; exists. It was just not the field we reached for.&lt;/p&gt;

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

&lt;p&gt;Same 259 companies, same day, only the date field changed:&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;with &lt;code&gt;updated_at&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;with &lt;code&gt;first_published&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;median age&lt;/td&gt;
&lt;td&gt;22 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51 days&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;older than 180 days&lt;/td&gt;
&lt;td&gt;5.5%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Greenhouse older than 180 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Greenhouse number is the tell. No board has zero stale reqs. 0.0% was not a finding, it was a bug report: the freshness signal had been pinned to "last edited", so every edit reset the clock.&lt;/p&gt;

&lt;p&gt;If you are filtering scraped jobs by age, or building an "only new since last run" delta on top of &lt;code&gt;posted_at&lt;/code&gt;, this bug is silent and total. Every stale req comes back as new, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full age distribution (6,833 postings that publish a salary)
&lt;/h2&gt;

&lt;p&gt;Measured 2026-10-24 from each board's own first-published field:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;p10: &lt;strong&gt;8 days&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;median: &lt;strong&gt;51 days&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;p90: &lt;strong&gt;236 days&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;oldest: &lt;strong&gt;2,628 days&lt;/strong&gt; (just over seven years)&lt;/li&gt;
&lt;li&gt;older than 30 days: &lt;strong&gt;65.9%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;older than 90 days: &lt;strong&gt;30.1%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;older than 180 days: &lt;strong&gt;14.2%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;older than a year: &lt;strong&gt;4.9%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the median "open" role has been open for close to two months. Nearly a third are past a quarter, and one in seven is past half a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boards differ, and the difference is the date semantics
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;Postings&lt;/th&gt;
&lt;th&gt;Median age&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;th&gt;Oldest&lt;/th&gt;
&lt;th&gt;&amp;gt;180d&lt;/th&gt;
&lt;th&gt;Date field&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;4,063&lt;/td&gt;
&lt;td&gt;52d&lt;/td&gt;
&lt;td&gt;236d&lt;/td&gt;
&lt;td&gt;2,628d&lt;/td&gt;
&lt;td&gt;14.5%&lt;/td&gt;
&lt;td&gt;&lt;code&gt;first_published&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;2,615&lt;/td&gt;
&lt;td&gt;46d&lt;/td&gt;
&lt;td&gt;215d&lt;/td&gt;
&lt;td&gt;1,325d&lt;/td&gt;
&lt;td&gt;12.7%&lt;/td&gt;
&lt;td&gt;&lt;code&gt;createdAt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lever&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;td&gt;77d&lt;/td&gt;
&lt;td&gt;527d&lt;/td&gt;
&lt;td&gt;2,230d&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30.3%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;publishedAt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Lever postings are older on median and much older in the tail. That is either a different hiring style or a board that keeps reqs visible after the hire — I cannot tell which from the data, so I am not going to dress it up as a conclusion.&lt;/p&gt;

&lt;p&gt;Companies with the longest-running open reqs (minimum five dated postings): Secureframe median &lt;strong&gt;442 days&lt;/strong&gt;, Zilliz 430, Character 310, Delfina 299. A stale listing is not proof of a fake job either. Plenty of teams keep a rolling req open for a role they hire into continuously. But if you are a candidate sorting by "newest", a rolling req sorted by &lt;code&gt;updated_at&lt;/code&gt; will follow you around forever.&lt;/p&gt;

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

&lt;p&gt;Before you trust any &lt;code&gt;posted_at&lt;/code&gt;, answer two questions about it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which field is it?&lt;/strong&gt; &lt;code&gt;first_published&lt;/code&gt; / &lt;code&gt;createdAt&lt;/code&gt; / &lt;code&gt;publishedAt&lt;/code&gt; are posting dates. &lt;code&gt;updated_at&lt;/code&gt; / &lt;code&gt;modified_at&lt;/code&gt; are edit dates. Mixing them is the bug above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the board even have the first kind?&lt;/strong&gt; If it only exposes last-modified, your ages are understated and no amount of downstream maths will fix it — report it separately rather than averaging it in with boards that do it right.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A cheap sanity test that would have caught this in minutes: compute the share of postings older than six months, per board. If any real board comes back at 0%, you are not measuring what you think you are measuring.&lt;/p&gt;




&lt;p&gt;The age numbers and the per-company breakdown live at &lt;a href="https://sjdfi0wjvosdju9.github.io/freshness.html" rel="noopener noreferrer"&gt;sjdfi0wjvosdju9.github.io/freshness.html&lt;/a&gt;, regenerated weekly from the companies' own public feeds. The underlying postings, with dates and salary ranges, are &lt;a href="https://sjdfi0wjvosdju9.github.io/data/salary-ranges-full.csv" rel="noopener noreferrer"&gt;a free CC BY 4.0 CSV&lt;/a&gt; — 6,833 rows across 259 companies. If you would rather not run the scraping yourself, the same data is available as a paid Apify Actor, and I would rather you read the free CSV first and decide whether it is worth $0.0015 a row.&lt;/p&gt;

</description>
      <category>data</category>
      <category>career</category>
      <category>api</category>
      <category>python</category>
    </item>
    <item>
      <title>Getting 6,826 job postings with salary as JSON in four minutes (and what it actually costs)</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Fri, 23 Oct 2026 19:48:05 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/getting-6826-job-postings-with-salary-as-json-in-four-minutes-and-what-it-actually-costs-37dk</link>
      <guid>https://dev.to/sdijjxcfs/getting-6826-job-postings-with-salary-as-json-in-four-minutes-and-what-it-actually-costs-37dk</guid>
      <description>&lt;p&gt;Most "job API" tutorials show you a toy call and stop before the part where you need 20,000 rows. This is the other version: the exact request, the exact response, the exact bill, and the four ways it will fail on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;p&gt;One call returns jobs from &lt;strong&gt;259 companies&lt;/strong&gt; across Greenhouse, Ashby, Lever, Workable, Personio and Workday. Strip the ones without pay and you are left with about &lt;strong&gt;6,826 postings that state an annual salary range&lt;/strong&gt;, normalised to USD.&lt;/p&gt;

&lt;p&gt;Each record looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ats"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ashby"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Research Scientist / Research Engineer - Foundations, Search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"San Francisco, CA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"remote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryLow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;380000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryHigh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;850000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryCurrency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryUsdLow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;380000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salaryUsdHigh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;850000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"postedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-06-16T22:35:00.347000+00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://jobs.ashbyhq.com/openai/…"&lt;/span&gt;&lt;span class="w"&gt;
&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;salaryUsdLow/High&lt;/code&gt; are the interesting fields: they exist because I convert hourly and monthly bands into a year and convert currencies, and because I keep the original numbers next to the normalised ones so you can second-guess me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The call
&lt;/h2&gt;

&lt;p&gt;No SDK, no pagination, no auth dance beyond an Apify token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/contributory_garnet_hyz~ats-job-feed/runs?token=&lt;/span&gt;&lt;span class="nv"&gt;$APIFY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;waitForFinish=600"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
        "useVerifiedPool": true,
        "salaryOnly": true,
        "minSalaryUsd": 150000,
        "maxPerCompany": 3,
        "maxJobs": 100
      }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That run takes about &lt;strong&gt;four minutes&lt;/strong&gt; and returns 100 jobs. The request-level knobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;input&lt;/th&gt;
&lt;th&gt;what it does&lt;/th&gt;
&lt;th&gt;default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useVerifiedPool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read the bundled 259-company list instead of the &lt;code&gt;companies&lt;/code&gt; you pass&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;companies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your own slugs or careers URLs (Workday &lt;strong&gt;needs&lt;/strong&gt; the full URL)&lt;/td&gt;
&lt;td&gt;13 well-known slugs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;salaryOnly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;drop postings without a published range&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minSalaryUsd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;drop below this annual midpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keywords&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;substring match on title&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxPerCompany&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stop one company eating the list&lt;/td&gt;
&lt;td&gt;&lt;code&gt;20&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxJobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hard cap on returned rows&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxWorkers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;parallel board requests (max 10)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;6&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;includeDescriptions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read Greenhouse job bodies (needed for their salaries)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;onlyNew&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;only return postings you have not seen in previous runs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;remoteOnly&lt;/code&gt;, &lt;code&gt;excludeSenior&lt;/code&gt;, &lt;code&gt;excludeJunior&lt;/code&gt;, &lt;code&gt;excludeNonTech&lt;/code&gt;, &lt;code&gt;maxAgeDays&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;obvious filters&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The bill, itemised
&lt;/h2&gt;

&lt;p&gt;This is the part nobody publishes. Real numbers from my own runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;$0.0005&lt;/strong&gt; per run start&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$0.0015&lt;/strong&gt; per job returned&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;platform usage&lt;/strong&gt;: about &lt;strong&gt;$0.006&lt;/strong&gt; with job descriptions on, about &lt;strong&gt;$0.0008&lt;/strong&gt; with them off&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: 100 jobs with descriptions ≈ $0.006 + $0.0005 + $0.15 = &lt;strong&gt;$0.157&lt;/strong&gt;. 50 jobs ≈ $0.08. A daily diff of ~20 new postings ≈ $0.04/day.&lt;/p&gt;

&lt;p&gt;That platform-usage line is 8x higher when &lt;code&gt;includeDescriptions&lt;/code&gt; is on, because Greenhouse descriptions are a &lt;strong&gt;144 MB&lt;/strong&gt; download. If you do not need salary for Greenhouse companies — or you are happy with the ~18% coverage that structured fields give you — turn it off and the run is twice as fast and an order of magnitude cheaper.&lt;/p&gt;

&lt;p&gt;But note the catch: &lt;strong&gt;&lt;code&gt;salaryOnly&lt;/code&gt; + &lt;code&gt;includeDescriptions: false&lt;/code&gt; will mostly return Ashby jobs&lt;/strong&gt;, because Greenhouse publishes pay in prose, not in metadata. You are choosing between cheap and complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways it breaks
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Slugs rot.&lt;/strong&gt; 527 candidate company slugs tested → 259 answered. Next month some of the 259 will 404. Every run returns &lt;code&gt;failedCompanies&lt;/code&gt; with the reason per board, e.g. &lt;code&gt;"greenhouse: empty; lever: HTTP 404; ashby: HTTP 404"&lt;/code&gt;. It does not silently return fewer rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workable is a shell.&lt;/strong&gt; Its public endpoint answers for 4 companies and returns an empty list with a 200. Treated as a failure, not as "this company is not hiring".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workday cannot be guessed.&lt;/strong&gt; Its board host is &lt;code&gt;{tenant}.wd{N}.myworkdayjobs.com&lt;/code&gt; and the root path returns &lt;strong&gt;HTTP 406&lt;/strong&gt;, so there is no redirect to discover the site name. I tested 24 large companies × 2 host numbers × 4 site names = 192 guesses and got exactly &lt;strong&gt;one&lt;/strong&gt; hit. That is why Workday needs a URL. Its list API also has no salary field at all, so a range can only come from the job description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse salaries are prose.&lt;/strong&gt; "The base salary range is 196,000 USD - 310,500 USD" — currency &lt;strong&gt;after&lt;/strong&gt; each number, plus postings that list Level 4 and Level 5 bands in one sentence. My first parser turned that into &lt;code&gt;196000–196000&lt;/code&gt;. If you build your own, test on NVIDIA's postings.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you only want the snapshot
&lt;/h2&gt;

&lt;p&gt;You do not need an account for the frozen version — 1,459 rows of it, CC BY 4.0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSV&lt;/strong&gt;: &lt;a href="https://sjdfi0wjvosdju9.github.io/data/salary-ranges.csv" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/data/salary-ranges.csv&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Method, per-company medians, publication rates per board&lt;/strong&gt;: &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/salary-data.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The live actor is &lt;a href="https://apify.com/contributory_garnet_hyz/ats-job-feed" rel="noopener noreferrer"&gt;https://apify.com/contributory_garnet_hyz/ats-job-feed&lt;/a&gt; — $0.0015 per job, and it is a one-person project, so if the parsing looks wrong somewhere I do want to hear about it.&lt;/p&gt;

&lt;p&gt;Two honest caveats: this is &lt;em&gt;published&lt;/em&gt; pay, not actual pay, and roughly 44% of postings publish nothing at all. Also, some companies post evergreen roles that stay up for a year — &lt;code&gt;maxAgeDays&lt;/code&gt; will not save you from those, because their posting date is genuinely old and they are genuinely still open.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Numbers revision (2026-10-24): the pipeline was merging distinct city-level requisitions under one title, so row counts here are lower than the current dataset. After fixing the dedupe key the same 259 companies yield **18,567 postings / 7,984 with published pay&lt;/em&gt;* (median $200,000). The current figures live on &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;the dataset page&lt;/a&gt;; the bug is written up &lt;a href="https://dev.to/sdijjxcfs/i-was-deleting-17-of-real-job-postings-with-one-line-of-dedupe-4002"&gt;here&lt;/a&gt;.*&lt;/p&gt;

</description>
      <category>career</category>
      <category>api</category>
      <category>data</category>
      <category>python</category>
    </item>
    <item>
      <title>Across 15,534 postings, remote engineering roles publish 11.5% lower pay than on-site ones</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Fri, 23 Oct 2026 19:33:43 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/across-15534-postings-remote-engineering-roles-publish-115-lower-pay-than-on-site-ones-3abe</link>
      <guid>https://dev.to/sdijjxcfs/across-15534-postings-remote-engineering-roles-publish-115-lower-pay-than-on-site-ones-3abe</guid>
      <description>&lt;p&gt;Last time I published what 259 companies' job boards say about pay. Two questions came out of the comments that I could actually answer with the same dataset, so I pulled a fresh snapshot (15,534 de-duplicated postings, 6,826 with a stated annual range) and looked at them.&lt;/p&gt;

&lt;p&gt;Short version: &lt;strong&gt;on-site engineering roles publish an 11.5% higher midpoint than remote engineering roles at the same companies&lt;/strong&gt;, and &lt;strong&gt;Lever barely publishes pay at all&lt;/strong&gt; — 9.5% of its postings, versus 53.5% on Ashby.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: which boards tell you the salary
&lt;/h2&gt;

&lt;p&gt;This is the part I had wrong in my head before measuring. I assumed Ashby was the pay-transparent board because it returns structured salary objects. It is, but only slightly more so than Greenhouse once you read Greenhouse's job descriptions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Board&lt;/th&gt;
&lt;th&gt;Postings read&lt;/th&gt;
&lt;th&gt;With a stated range&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;4,879&lt;/td&gt;
&lt;td&gt;2,612&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;8,933&lt;/td&gt;
&lt;td&gt;4,059&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;45.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lever&lt;/td&gt;
&lt;td&gt;1,640&lt;/td&gt;
&lt;td&gt;156&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workable&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;td&gt;0&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;The denominator matters: that is every posting read from the board, not just the ones with pay. So this is a publication rate, not a composition effect.&lt;/p&gt;

&lt;p&gt;Two practical consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ashby gives you structured numbers&lt;/strong&gt; (&lt;code&gt;minValue&lt;/code&gt;, &lt;code&gt;maxValue&lt;/code&gt;, &lt;code&gt;currencyCode&lt;/code&gt;, &lt;code&gt;interval&lt;/code&gt;) for half its postings. Cheap and exact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse gives you almost as much coverage but as free text&lt;/strong&gt; inside a 144 MB download of job descriptions. You have to find "the base salary range is 210,000 USD - 310,500 USD" in a wall of marketing copy, and that is where I generated three spectacular parsing bugs last time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever is where salary data goes to die.&lt;/strong&gt; Clean schema, almost nobody uses it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building a pay dataset and you have to pick one board, pick Ashby. If you need volume, Greenhouse — and budget for the parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second: the remote gap
&lt;/h2&gt;

&lt;p&gt;Overall, remote postings in this sample have a median midpoint of &lt;strong&gt;$191,000&lt;/strong&gt; against &lt;strong&gt;$211,500&lt;/strong&gt; for on-site. That is a 9.7% gap, and the obvious objection is composition: maybe remote postings are concentrated in lower-paying functions.&lt;/p&gt;

&lt;p&gt;So I compared remote and on-site &lt;em&gt;within the same role family&lt;/em&gt;, requiring at least 60 postings on each side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role family&lt;/th&gt;
&lt;th&gt;Remote median&lt;/th&gt;
&lt;th&gt;On-site median&lt;/th&gt;
&lt;th&gt;Gap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;other&lt;/td&gt;
&lt;td&gt;$161,968&lt;/td&gt;
&lt;td&gt;$198,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−18.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;support / ops&lt;/td&gt;
&lt;td&gt;$140,600&lt;/td&gt;
&lt;td&gt;$170,260&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−17.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;engineering&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$207,094&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$234,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−11.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;data / ml&lt;/td&gt;
&lt;td&gt;$208,437&lt;/td&gt;
&lt;td&gt;$226,000&lt;/td&gt;
&lt;td&gt;−7.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;marketing&lt;/td&gt;
&lt;td&gt;$169,025&lt;/td&gt;
&lt;td&gt;$182,750&lt;/td&gt;
&lt;td&gt;−7.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;product&lt;/td&gt;
&lt;td&gt;$220,000&lt;/td&gt;
&lt;td&gt;$230,000&lt;/td&gt;
&lt;td&gt;−4.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sales&lt;/td&gt;
&lt;td&gt;$166,500&lt;/td&gt;
&lt;td&gt;$156,875&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+6.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap survives controlling for function. It is biggest in support/ops and in the catch-all bucket, solid in engineering, and it &lt;strong&gt;reverses for sales&lt;/strong&gt; — remote sales roles publish 6.1% &lt;em&gt;more&lt;/em&gt;, which makes sense if you think of remote sales as covering bigger or more scattered territories.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I cannot tell you
&lt;/h2&gt;

&lt;p&gt;I can't separate two explanations, and I want to be blunt about it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The same job pays less when it is remote.&lt;/strong&gt; Companies discount for location independence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote roles are open to cheaper geographies&lt;/strong&gt;, so the published range reflects a national or global band rather than a San Francisco one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both produce exactly the pattern above. The dataset has a &lt;code&gt;location&lt;/code&gt; column, and you could partly untangle it by splitting "remote (US)" from "remote (anywhere)" — but a large share of postings just say "Remote" with no country, so any split I do here would be me assigning geography by vibes. Not doing that.&lt;/p&gt;

&lt;p&gt;There is also a smaller caveat: medians mix seniority. A company posting mostly staff-level on-site roles and mostly mid-level remote ones would show this gap without any remote discount existing. I have &lt;code&gt;title&lt;/code&gt;, so a title-based seniority regex is the obvious next step, and it is on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is actually useful
&lt;/h2&gt;

&lt;p&gt;If you are negotiating a remote offer, the interesting number is not "what do remote roles pay" — it is &lt;strong&gt;what does this company publish for the on-site version of my job&lt;/strong&gt;. That is a concrete anchor, it comes from the same salary band, and it is a legitimate question: "I noticed the on-site posting for this role has a higher band; is the remote band adjusted for location, and if so, to what?"&lt;/p&gt;

&lt;p&gt;In engineering the median difference at these companies is $26,906. That is worth one email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the data
&lt;/h2&gt;

&lt;p&gt;Same free CSV as before, refreshed: &lt;strong&gt;1,459 rows&lt;/strong&gt;, stratified so all 259 companies appear, CC BY 4.0, no signup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSV&lt;/strong&gt;: &lt;a href="https://sjdfi0wjvosdju9.github.io/data/salary-ranges.csv" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/data/salary-ranges.csv&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page with the method, per-company medians and the full tables&lt;/strong&gt;: &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/salary-data.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stats as JSON&lt;/strong&gt;, if you want to check my arithmetic: &lt;a href="https://sjdfi0wjvosdju9.github.io/data/salary-stats.json" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/data/salary-stats.json&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;remote&lt;/code&gt; column is &lt;code&gt;yes&lt;/code&gt;/&lt;code&gt;no&lt;/code&gt; straight from the board, and &lt;code&gt;role_family&lt;/code&gt; is my own regex over the title — so treat the family table as indicative, not as a classification anyone agreed on.&lt;/p&gt;

&lt;p&gt;If your own company contradicts the engineering number, I would like to hear it. One counter-example with a real band is worth more than my median.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Numbers revision (2026-10-24): the pipeline was merging distinct city-level requisitions under one title, so row counts here are lower than the current dataset. After fixing the dedupe key the same 259 companies yield **18,567 postings / 7,984 with published pay&lt;/em&gt;* (median $200,000). The current figures live on &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;the dataset page&lt;/a&gt;; the bug is written up &lt;a href="https://dev.to/sdijjxcfs/i-was-deleting-17-of-real-job-postings-with-one-line-of-dedupe-4002"&gt;here&lt;/a&gt;.*&lt;/p&gt;




&lt;h3&gt;
  
  
  Numbers revision (2026-10-24, role classifier fix)
&lt;/h3&gt;

&lt;p&gt;The role-family buckets in the earlier numbers came from a single-tier classifier&lt;br&gt;
that matched &lt;code&gt;engineer&lt;/code&gt; before anything else, so &lt;strong&gt;Sales Engineer, Solutions&lt;br&gt;
Engineer and Support Engineer were all counted as engineering&lt;/strong&gt;, and the word&lt;br&gt;
&lt;code&gt;data&lt;/code&gt; pulled data-center technicians into data/ML. That is also why &lt;code&gt;other&lt;/code&gt;&lt;br&gt;
reached 21.5% of rows.&lt;/p&gt;

&lt;p&gt;Rebuilt with a four-tier classifier (disambiguation -&amp;gt; specific -&amp;gt; generic -&amp;gt;&lt;br&gt;
broad) and 21 unit tests. Same 8,041 rows; only the buckets moved:&lt;br&gt;
engineering 39.7% -&amp;gt; 27.4%, sales 10.8% -&amp;gt; 15.4%, support / ops 6.3% -&amp;gt; 15.2%,&lt;br&gt;
finance / legal 3.3% -&amp;gt; 9.1%, &lt;strong&gt;other 21.5% -&amp;gt; 8.5%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Consequence for the remote-vs-on-site gap: engineering was &lt;strong&gt;-11.5%&lt;/strong&gt;; after the&lt;br&gt;
fix it is &lt;strong&gt;-6.2%&lt;/strong&gt; (remote median $212,000 vs on-site $226,000, n=563 / 1,638).&lt;br&gt;
The direction holds, the magnitude shrinks. Current numbers:&lt;br&gt;
&lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/salary-data.html&lt;/a&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>salary</category>
      <category>data</category>
      <category>remote</category>
    </item>
    <item>
      <title>What 259 companies actually publish as pay: 6,823 job postings, normalised to USD</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Fri, 23 Oct 2026 19:24:29 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/what-259-companies-actually-publish-as-pay-6823-job-postings-normalised-to-usd-4b04</link>
      <guid>https://dev.to/sdijjxcfs/what-259-companies-actually-publish-as-pay-6823-job-postings-normalised-to-usd-4b04</guid>
      <description>&lt;p&gt;I scraped 259 companies' job boards this week: 15,532 live postings after de-duplication. &lt;strong&gt;6,823 of them state an annual salary range — 43.9%.&lt;/strong&gt; I normalised all of them to USD and put the whole thing in a free CSV, because the numbers turned out more interesting than the tool I built to collect them.&lt;/p&gt;

&lt;p&gt;Here is what they say.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distribution
&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;Midpoint of the published range&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p25&lt;/td&gt;
&lt;td&gt;$154,700&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;median&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$204,750&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p75&lt;/td&gt;
&lt;td&gt;$260,630&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p90&lt;/td&gt;
&lt;td&gt;$335,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;29.9% of the salary-bearing postings are remote.&lt;/p&gt;

&lt;p&gt;A warning before anyone quotes that median: this is a census of &lt;em&gt;published&lt;/em&gt; ranges, not a survey of what everyone earns. Companies that publish pay skew towards US tech, and towards senior roles. The 259-company pool was itself built by testing 527 slugs and keeping the 259 that answered, so it over-represents companies with a modern ATS and an explicit pay-transparency policy. Treat $204,750 as "the middle of what these 259 companies chose to write down", not "the middle of the market".&lt;/p&gt;

&lt;h2&gt;
  
  
  Who publishes, and who does not
&lt;/h2&gt;

&lt;p&gt;The four boards I read behave very differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ashby&lt;/strong&gt; returns salary as structured numbers (&lt;code&gt;minValue&lt;/code&gt; / &lt;code&gt;maxValue&lt;/code&gt; / &lt;code&gt;currencyCode&lt;/code&gt; / &lt;code&gt;interval&lt;/code&gt;). About &lt;strong&gt;52%&lt;/strong&gt; of its postings carry one. Nothing to guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greenhouse&lt;/strong&gt; has a metadata field, but with &lt;code&gt;content=false&lt;/code&gt; it comes back empty for every single posting. The salary is loose text inside the job body instead — and roughly &lt;strong&gt;44%&lt;/strong&gt; of postings have one. You have to read the description and find the range.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lever&lt;/strong&gt; has a clean &lt;code&gt;salaryRange&lt;/code&gt; object, and &lt;strong&gt;9%&lt;/strong&gt; of postings use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workable&lt;/strong&gt; answered for 4 companies and published no pay at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So of the 6,823 paid postings: 4,058 came from Greenhouse, 2,609 from Ashby, 156 from Lever.&lt;/p&gt;

&lt;p&gt;That matters if you are choosing where to look. If you want &lt;em&gt;money data&lt;/em&gt;, Ashby is the cheapest source per row. If you want &lt;em&gt;volume&lt;/em&gt;, Greenhouse wins on postings but you pay for it in parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Highest published medians (companies with ≥3 paid postings)
&lt;/h2&gt;

&lt;p&gt;161 companies cleared the three-posting bar. The top of the list is not subtle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;Postings&lt;/th&gt;
&lt;th&gt;p25&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;p75&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;anthropic&lt;/td&gt;
&lt;td&gt;568&lt;/td&gt;
&lt;td&gt;$287,500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$347,500&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$402,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;openai&lt;/td&gt;
&lt;td&gt;631&lt;/td&gt;
&lt;td&gt;$248,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$307,500&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$366,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cohere&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;td&gt;$204,400&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$259,150&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$286,525&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;perplexity&lt;/td&gt;
&lt;td&gt;106&lt;/td&gt;
&lt;td&gt;$195,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$247,500&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$312,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vercel&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;td&gt;$195,500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$245,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$260,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;render&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;$230,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$242,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$259,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;figma&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;$207,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$236,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$264,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;modal&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;$200,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$231,250&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$275,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vanta&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;$212,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$229,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$264,500&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These medians mix seniority levels. A company hiring mostly staff engineers looks expensive; a company hiring support staff looks cheap. It is a composition effect as much as a pay effect. The first two rows are also the two with the most postings, which is a coincidence I would not read too much into — but it does tell you where the AI labs are setting the ceiling right now.&lt;/p&gt;

&lt;p&gt;By role family, with at least 20 postings each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Postings&lt;/th&gt;
&lt;th&gt;Median midpoint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;product&lt;/td&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;td&gt;$225,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;engineering&lt;/td&gt;
&lt;td&gt;2,677&lt;/td&gt;
&lt;td&gt;$225,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;data / ml&lt;/td&gt;
&lt;td&gt;430&lt;/td&gt;
&lt;td&gt;$221,337&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;finance / legal&lt;/td&gt;
&lt;td&gt;229&lt;/td&gt;
&lt;td&gt;$202,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;security&lt;/td&gt;
&lt;td&gt;109&lt;/td&gt;
&lt;td&gt;$195,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;design&lt;/td&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;$185,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;marketing&lt;/td&gt;
&lt;td&gt;335&lt;/td&gt;
&lt;td&gt;$179,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sales&lt;/td&gt;
&lt;td&gt;697&lt;/td&gt;
&lt;td&gt;$160,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;support / ops&lt;/td&gt;
&lt;td&gt;430&lt;/td&gt;
&lt;td&gt;$158,875&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Product managers and engineers land within $500 of each other, which surprised me. Sales spreads widest in absolute terms, because commission-heavy roles publish base ranges that mean very little on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parsing is where the lies live
&lt;/h2&gt;

&lt;p&gt;Every one of these numbers went through a normaliser, and the normaliser produced three spectacular wrong answers before I made it stricter. All three are real examples from this dataset:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A monthly range labelled as USD.&lt;/strong&gt; One Lever posting had &lt;code&gt;{min: 128000, max: 176000, currency: "USD", interval: "per-month-salary"}&lt;/code&gt;. Multiply by twelve and it becomes &lt;strong&gt;$1,536,000 – $2,112,000&lt;/strong&gt; for a DevOps engineer. That number went to the top of my leaderboard and I nearly published it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The word "hourly" anywhere in the description.&lt;/strong&gt; A Greenhouse job description said "the base salary range is 210,000 USD - 310,000 USD" in one paragraph and mentioned "hourly employees" in another. My annualiser saw the word and multiplied by 2,080: &lt;strong&gt;$436,800,000&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A genuinely hourly contract.&lt;/strong&gt; &lt;code&gt;$20.67 – $21.88&lt;/code&gt; went in as an annual salary, because the guard at the time only rejected values below $100.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix that finally held was unglamorous: read the currency from the &lt;em&gt;end&lt;/em&gt; of each number, not the start, only apply an hourly multiplier when the range actually looks hourly (floor under $500), and then throw away anything outside &lt;strong&gt;$5,000 – $5,000,000&lt;/strong&gt; after normalisation instead of trying to repair it.&lt;/p&gt;

&lt;p&gt;The last rule costs me rows. GoCardless and Coinbase both published monthly figures I could not prove were monthly, so those rows simply have no salary now. Empty beats confidently wrong. If someone makes a career decision off this CSV, the only acceptable failure mode is a blank cell.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is in the file
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;salary-ranges.csv&lt;/code&gt; — 1,460 rows, stratified so every company in the pool appears at least once. One row per posting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;company&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;ats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;family&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;salary&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;usd&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;low&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;salary&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;usd&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;high&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;salary&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;usd&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;posted&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;url&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CC BY 4.0. No signup, no email gate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSV&lt;/strong&gt;: &lt;a href="https://sjdfi0wjvosdju9.github.io/data/salary-ranges.csv" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/data/salary-ranges.csv&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The page with the method and the full tables&lt;/strong&gt;: &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/salary-data.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How I picked the 259 companies&lt;/strong&gt; (55 niches measured, several of them rejected with numbers): &lt;a href="https://sjdfi0wjvosdju9.github.io/how-we-choose.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/how-we-choose.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Captured 2026-10-24. Job boards rotate slugs constantly, so expect a handful of companies to 404 next week — the tool reports those in &lt;code&gt;failedCompanies&lt;/code&gt; rather than silently returning less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am giving it away
&lt;/h2&gt;

&lt;p&gt;I built a paid actor that returns this data live (&lt;a href="https://apify.com/contributory_garnet_hyz/ats-job-feed" rel="noopener noreferrer"&gt;https://apify.com/contributory_garnet_hyz/ats-job-feed&lt;/a&gt;, $0.0015 per job), and the honest situation is that nobody knows it exists yet. A CSV with a real number in it is a better introduction than another landing page.&lt;/p&gt;

&lt;p&gt;If you want the refreshed version rather than the snapshot: set &lt;code&gt;useVerifiedPool: true&lt;/code&gt; and &lt;code&gt;salaryOnly: true&lt;/code&gt; on that actor and it returns the same schema for the current day, 259 companies, 259 HTTP requests, about four minutes.&lt;/p&gt;

&lt;p&gt;Two things I would genuinely like feedback on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the &lt;strong&gt;midpoint&lt;/strong&gt; the right summary? Right now I rank by it, but a $150k–$350k range and a $240k–$260k range have the same midpoint and are not the same offer.&lt;/li&gt;
&lt;li&gt;Should I keep rows where the range came from free text rather than a structured field? It costs a 144 MB download to read Greenhouse descriptions and it is where most of the garbage lived.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you spot a salary in that CSV that is obviously wrong, tell me — I would rather fix the parser than defend it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Numbers revision (2026-10-24): the pipeline was merging distinct city-level requisitions under one title, so row counts here are lower than the current dataset. After fixing the dedupe key the same 259 companies yield **18,567 postings / 7,984 with published pay&lt;/em&gt;* (median $200,000). The current figures live on &lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;the dataset page&lt;/a&gt;; the bug is written up &lt;a href="https://dev.to/sdijjxcfs/i-was-deleting-17-of-real-job-postings-with-one-line-of-dedupe-4002"&gt;here&lt;/a&gt;.*&lt;/p&gt;




&lt;h3&gt;
  
  
  Numbers revision (2026-10-24, role classifier fix)
&lt;/h3&gt;

&lt;p&gt;The role-family buckets in the earlier numbers came from a single-tier classifier&lt;br&gt;
that matched &lt;code&gt;engineer&lt;/code&gt; before anything else, so &lt;strong&gt;Sales Engineer, Solutions&lt;br&gt;
Engineer and Support Engineer were all counted as engineering&lt;/strong&gt;, and the word&lt;br&gt;
&lt;code&gt;data&lt;/code&gt; pulled data-center technicians into data/ML. That is also why &lt;code&gt;other&lt;/code&gt;&lt;br&gt;
reached 21.5% of rows.&lt;/p&gt;

&lt;p&gt;Rebuilt with a four-tier classifier (disambiguation -&amp;gt; specific -&amp;gt; generic -&amp;gt;&lt;br&gt;
broad) and 21 unit tests. Same 8,041 rows; only the buckets moved:&lt;br&gt;
engineering 39.7% -&amp;gt; 27.4%, sales 10.8% -&amp;gt; 15.4%, support / ops 6.3% -&amp;gt; 15.2%,&lt;br&gt;
finance / legal 3.3% -&amp;gt; 9.1%, &lt;strong&gt;other 21.5% -&amp;gt; 8.5%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Consequence for the remote-vs-on-site gap: engineering was &lt;strong&gt;-11.5%&lt;/strong&gt;; after the&lt;br&gt;
fix it is &lt;strong&gt;-6.2%&lt;/strong&gt; (remote median $212,000 vs on-site $226,000, n=563 / 1,638).&lt;br&gt;
The direction holds, the magnitude shrinks. Current numbers:&lt;br&gt;
&lt;a href="https://sjdfi0wjvosdju9.github.io/salary-data.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/salary-data.html&lt;/a&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>salary</category>
      <category>data</category>
      <category>python</category>
    </item>
    <item>
      <title>Six APIs, zero keys: what 18,825 real job postings contain</title>
      <dc:creator>Radar Labs</dc:creator>
      <pubDate>Thu, 22 Oct 2026 21:27:25 +0000</pubDate>
      <link>https://dev.to/sdijjxcfs/six-apis-zero-keys-what-18825-real-job-postings-contain-4a7a</link>
      <guid>https://dev.to/sdijjxcfs/six-apis-zero-keys-what-18825-real-job-postings-contain-4a7a</guid>
      <description>&lt;p&gt;I wanted a job feed I could filter myself, so I tried to fetch one from the source instead of the aggregators. It turns out that most company career pages are thin wrappers around a handful of applicant tracking systems (ATS), and those systems expose JSON endpoints without keys or login.&lt;/p&gt;

&lt;p&gt;Six of them answered. I guessed 527 plausible company slugs, &lt;strong&gt;259 responded&lt;/strong&gt;, and that produced &lt;strong&gt;18,825 real job postings&lt;/strong&gt;. Here is what I found in them — including two bugs that silently published numbers that were off by two orders of magnitude.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six endpoints
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ATS&lt;/th&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Auth&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;&lt;code&gt;boards-api.greenhouse.io/v1/boards/{slug}/jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lever&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.lever.co/v0/postings/{slug}?mode=json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.ashbyhq.com/posting-api/job-board/{slug}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none (add &lt;code&gt;?includeCompensation=true&lt;/code&gt; for pay)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apply.workable.com/api/v1/widget/accounts/{slug}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personio&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{slug}.jobs.personio.de/xml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workday&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST {tenant}.wd{N}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All public, all read-only, none of them need an account. Some have changed shape or disappeared: SmartRecruiters returned 404 on both endpoints I tried, and Teamtailor customers usually sit behind their own domain so the documented JSON path just 404s.&lt;/p&gt;

&lt;p&gt;Workday is the awkward one. It needs a tenant, a cluster number and a site name before you can address a board at all, and guessing is close to hopeless: 24 well-known companies × 2 clusters × 4 site names = 192 probes, &lt;strong&gt;exactly one&lt;/strong&gt; hit. Everything else had to be supplied by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a sweep returns
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;18,825&lt;/strong&gt; postings from 259 companies (49% of guessed slugs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8,071 with a published salary (43%)&lt;/strong&gt; — but that number only exists because of the mistake below, which is the most interesting thing I learned&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finding 1: I was wrong about Greenhouse
&lt;/h2&gt;

&lt;p&gt;My first sweep looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ATS&lt;/th&gt;
&lt;th&gt;Companies&lt;/th&gt;
&lt;th&gt;Postings&lt;/th&gt;
&lt;th&gt;With salary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;135&lt;/td&gt;
&lt;td&gt;11,608&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;td&gt;5,274&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,734&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lever&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;1,810&lt;/td&gt;
&lt;td&gt;160&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workable&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;121&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;93% of every salary-bearing row came from Ashby. Greenhouse had the volume and essentially no pay, so the obvious conclusion was "Greenhouse doesn't publish salaries".&lt;/p&gt;

&lt;p&gt;That conclusion was wrong, and the reason is that I was asking for the wrong payload. &lt;code&gt;?content=false&lt;/code&gt; returns postings with the &lt;code&gt;metadata&lt;/code&gt; array set to &lt;code&gt;null&lt;/code&gt; for all of them. Ask for &lt;code&gt;?content=true&lt;/code&gt; and the pay range is sitting right there in the description text — &lt;strong&gt;44% of Greenhouse postings have one&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ATS&lt;/th&gt;
&lt;th&gt;Companies&lt;/th&gt;
&lt;th&gt;Postings&lt;/th&gt;
&lt;th&gt;With salary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;134&lt;/td&gt;
&lt;td&gt;11,619&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5,164 (44%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ashby&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;td&gt;5,283&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,739 (52%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lever&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;1,802&lt;/td&gt;
&lt;td&gt;168 (9%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workable&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;121&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trap is that Greenhouse's &lt;code&gt;content&lt;/code&gt; field is &lt;strong&gt;double-escaped HTML&lt;/strong&gt;: the description literally contains &lt;code&gt;&amp;amp;lt;div class="content-intro"&amp;amp;gt;&lt;/code&gt;. One &lt;code&gt;html.unescape&lt;/code&gt; is not enough. If you unescape once and search for &lt;code&gt;$&lt;/code&gt;, you find nothing and conclude there is no salary. You need to unescape until the string stops changing (I cap it at three rounds).&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: Ashby hides its compensation behind one flag
&lt;/h2&gt;

&lt;p&gt;Ashby returns beautiful structured compensation — &lt;code&gt;compensationTiers&lt;/code&gt;, &lt;code&gt;summaryComponents&lt;/code&gt; with &lt;code&gt;minValue&lt;/code&gt;, &lt;code&gt;maxValue&lt;/code&gt;, &lt;code&gt;currencyCode&lt;/code&gt;, &lt;code&gt;interval&lt;/code&gt; — but &lt;strong&gt;only when you pass &lt;code&gt;?includeCompensation=true&lt;/code&gt;&lt;/strong&gt;. Without the flag you get a gorgeous list of jobs and zero numbers. Same failure mode as Greenhouse, different cause: the API is right, the request is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the $1,536,000 ghost
&lt;/h2&gt;

&lt;p&gt;Lever returns structured ranges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"min"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;128000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;176000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"interval"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"per-month-salary"&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;That is a monthly salary — in a Latin American band — labeled &lt;code&gt;USD&lt;/code&gt;. Annualise it and you get &lt;strong&gt;$1,536,000&lt;/strong&gt; for a DevOps engineer. It briefly topped the entire scan and looked completely legitimate, because it was a well-formed number in a well-formed field.&lt;/p&gt;

&lt;p&gt;Then this one, from a long Greenhouse description:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The base salary range is 210,000 USD - 310,000 USD. Some hourly employees exist.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word "hourly" appears somewhere in the text, so my annualiser multiplied an annual range by 2,080 and produced &lt;strong&gt;$436,800,000&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Both bugs share a shape: &lt;strong&gt;the output was plausible&lt;/strong&gt;. A range that is off by 12× or 2,080× does not look broken; it looks like a senior role at a rich company.&lt;/p&gt;

&lt;p&gt;I stopped trying to repair these and started deleting them. Anything that annualises below $5,000, above $5,000,000, or above $1,500,000 in USD loses its salary fields entirely while the posting stays. And the unit words are only read from the ~40 characters &lt;strong&gt;after the last number&lt;/strong&gt; in the sentence, not from the whole description:&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;_unit_tail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# "…210,000 USD - 310,000 USD. Some hourly employees exist."
&lt;/span&gt;    &lt;span class="c1"&gt;# -&amp;gt; only look at " USD." , so "hourly" later in the text cannot fire
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since then the maximum salary in a full sweep is $850,000, which belongs to the posting it is attached to.&lt;/p&gt;

&lt;p&gt;Lesson: in a data product, one wrong order of magnitude is worse than one missing field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: the pay range is not always a number
&lt;/h2&gt;

&lt;p&gt;American postings often write the currency after &lt;em&gt;each&lt;/em&gt; number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The base salary range is 196,000 USD - 310,500 USD.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My regex was built for &lt;code&gt;$196,000 - $310,500&lt;/code&gt;, so it matched the first number plus "USD", stopped, and produced &lt;code&gt;196000 - 196000&lt;/code&gt;. The output looked perfect — a single plausible number — which is exactly the kind of bug that ships. You need a separate pattern for &lt;code&gt;number currency - number currency&lt;/code&gt;, and you need to decide what to do about postings that list several bands (&lt;code&gt;… for Level 4, and … for Level 5&lt;/code&gt;); I take the first band and say so in the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5: dedupe and "only new" are the real features
&lt;/h2&gt;

&lt;p&gt;Large companies post the same role on several boards, so a raw sweep duplicates heavily: &lt;strong&gt;18,825 rows collapsed to 15,528&lt;/strong&gt;. And if you (or your users) pay per row, the feature everyone actually wants is "only the postings I have not seen yet" — which needs a stable fingerprint per posting plus somewhere to persist the seen set. Mine is &lt;code&gt;company + normalised title + url&lt;/code&gt;, hashed, stored in a key-value store, capped at 50k entries, FIFO.&lt;/p&gt;

&lt;p&gt;The normalisation detail that matters: strip punctuation and case, but &lt;strong&gt;do not&lt;/strong&gt; strip location qualifiers. &lt;code&gt;Engineer (Remote)&lt;/code&gt; and &lt;code&gt;Engineer (Berlin)&lt;/code&gt; are different jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try this yourself
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Do not trust labels, tags or category counts — measure the payload.&lt;/li&gt;
&lt;li&gt;Ask for compensation explicitly (Ashby), and fall back to reading the description (Greenhouse, Workday).&lt;/li&gt;
&lt;li&gt;Guard every annualisation with a plausibility bound, and &lt;strong&gt;drop rather than repair&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Deduplicate on &lt;code&gt;company + normalised title + url&lt;/code&gt;, never on title alone.&lt;/li&gt;
&lt;li&gt;Store a fingerprint per posting if you want incremental results.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;Slug guessing worked 49% of the time. Workable boards often answer with an empty shell. Some postings are evergreen and never close, so "new" is fuzzy at the edges. Salary only exists when a company chooses to publish it — 43% of rows here, not 100%. And a wrong number is worse than a missing one, so I drop aggressively and tell users when a company returned nothing.&lt;/p&gt;

&lt;p&gt;I packaged this as a small tool that scans the six APIs, normalises salaries to annual USD, dedupes across boards and can return only new postings: &lt;a href="https://apify.com/contributory_garnet_hyz/ats-job-feed" rel="noopener noreferrer"&gt;ATS Job Feed&lt;/a&gt;. It ships the list of 259 companies that were verified live, so you do not have to guess slugs, and it can return only postings that actually state a salary.&lt;/p&gt;

&lt;p&gt;If you pull job data for a living: which of these six APIs has bitten you the hardest? I would like to know which shape I should handle next.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Inspect the actual rows before trusting any of these numbers.&lt;/strong&gt; Both sample outputs are mirrored as static tables — job feed: &lt;a href="https://sjdfi0wjvosdju9.github.io/samples/ats-jobs.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/samples/ats-jobs.html&lt;/a&gt;, lead radar: &lt;a href="https://sjdfi0wjvosdju9.github.io/samples/freelance-leads.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/samples/freelance-leads.html&lt;/a&gt;. No account needed. The selection notes (including the markets I rejected) are at &lt;a href="https://sjdfi0wjvosdju9.github.io/how-we-choose.html" rel="noopener noreferrer"&gt;https://sjdfi0wjvosdju9.github.io/how-we-choose.html&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>data</category>
      <category>career</category>
    </item>
  </channel>
</rss>
