Use case

Benchmarking pay and talent demand for workforce planning

A workforce plan needs the pay employers advertise for a role and how many of them are competing for it. Here is how to build both from job postings.

Updated 5 October 20267 min read

A workforce plan needs two numbers for each role: the pay employers advertise for it and the number of employers advertising. This guide shows how a compensation or workforce planning team can build both from Job Postings across companies, with Hiring Activity, Sales Pay Benchmarks and Market Series alongside, by function and country, how to turn them into a pay range and a demand trend, and which traps make a benchmark look more certain than it is.

What you can benchmark

Every posting on a company's own job board is a dated record with a place, a job function, a seniority and, where the employer states it, a pay range. The table lists the questions a plan asks and where each is answered. Pay and demand answer different questions, so read them side by side for the same cut: one function, one seniority band, one country.

QuestionDataset and columnsUnit
What range do employers advertise for a role?Job Postings: salary_usd_annual_min, salary_usd_annual_max, country, and the function and seniority in labelsYearly US dollars
How many roles opened lately?Job Postings: first_seen_at, found_on_first_readPostings per period
How many companies are hiring for it?Job Postings: company_idCompanies
How does demand move across the market?Market Series: hiring_new, function_sharePer 100 companies, share of postings
What is the market pay level?Market Series: pay_medianMedian yearly US dollars
What do sales roles pay?Sales Pay Benchmarks: sales_pay_benchmarksQuartiles of base pay and on-target earnings
How long does a role stay advertised?Job Postings: first_seen_at, closed_atDays

Mapping your roles to the labels

Your job architecture has families and levels; the postings carry a function from a list of 26 and a seniority from nine levels: intern, entry, mid, senior, lead_principal, manager, director, vp and c_level. Map each of your roles to one function and one or two seniority values, and record the mapping so that a benchmark can be rerun the same way. Then read the title of a sample of postings in each cut to confirm that they describe your role. Where a role spans two functions, such as analytics engineering, run both cuts and compare them before you choose.

From postings to a pay range

Start with the cut you plan for. Take the postings in it that state pay, count the postings and the companies behind them, and read the range at three points. The query keeps every posting that was open at any time in the last 90 days, including those already advertised when the company's observation began, because a range already advertised is still a range employers advertise. The SQL is PostgreSQL, with lists and objects held as JSON as delivered.

with p as (
  select
    company_id,
    country,
    labels::jsonb ->> 'job_function' as job_function,
    labels::jsonb ->> 'seniority'    as seniority,
    salary_usd_annual_min            as pay_min,
    salary_usd_annual_max            as pay_max
  from job_postings
  where salary_usd_annual_min is not null
    and salary_usd_annual_max is not null
    and (closed_at is null or closed_at >= current_date - 90)
)
select
  country, job_function, seniority,
  count(*)                   as postings,
  count(distinct company_id) as companies,
  percentile_cont(0.25) within group (order by pay_min)              as low_p25,
  percentile_cont(0.50) within group (order by (pay_min + pay_max) / 2) as mid_median,
  percentile_cont(0.75) within group (order by pay_max)              as high_p75
from p
where job_function = 'data_science_ml'
  and seniority in ('senior', 'lead_principal')
  and country in ('GB', 'DE', 'FR')
group by 1, 2, 3
having count(*) >= 20 and count(distinct company_id) >= 8;

Four habits keep the result honest.

Read both ends of the range. The query returns the lower quartile of range minimums, which shows where employers start an offer, the upper quartile of range maximums, which shows the ceiling, and the median of midpoints between them as a reference point. A midpoint alone hides the width of a range, and advertised ranges can be wide.

Weight by company. One employer that posts forty near-identical roles will set the median of postings. Take each company's median first and then the median across companies, or cap each company at a few postings.

Set a floor and say so. Fokals writes a market pay row only over at least 20 postings, and a sales pay benchmark only for a group of at least five. For a plan, use at least 20 postings from at least eight companies, as the query does, and print both counts beside the range.

Keep currency in view. The US-dollar columns convert at a reference exchange rate, so a move in an exchange rate can read as a move in pay. For one country over time, work from salary_min, salary_max, salary_currency and salary_period as stated.

Pay and demand move at different speeds, so refresh them on different clocks. Rebuild the pay range each quarter from 90 days of postings, and again when you open a requisition for a role that is hard to fill. Rerun demand every week.

From postings to demand

Demand has a level and a direction. Count the postings that opened in the period and leave out those flagged found_on_first_read: they were already advertised when the company's observation began, so they did not open in your period.

select
  country,
  labels::jsonb ->> 'job_function' as job_function,
  count(*) filter (where first_seen_at >= current_date - 28)                    as opened_28d,
  count(*) filter (where first_seen_at <  current_date - 28)                    as opened_prior_28d,
  count(distinct company_id) filter (where first_seen_at >= current_date - 28) as hiring_companies
from job_postings
where found_on_first_read = false
  and first_seen_at >= current_date - 56
  and country in ('GB', 'DE', 'FR')
group by 1, 2
having count(*) filter (where first_seen_at >= current_date - 28) >= 20
order by country, opened_28d desc;

Read three counts together: postings opened, the same count for the 28 days before, and the number of companies behind them. Postings rising at a few companies is a different market from postings rising at many. The table pairs demand with the advertised range from the previous section.

Postings openedHiring companiesAdvertised rangeCan read as
UpUpUpMore employers competing, and paying more to do it
UpFlatFlatA few employers scaling up, with little change across the market
FlatDownUpFewer employers competing, and those left advertising higher ranges

To see whether your cut moves with the wider market, compare it with Market Series, which gives postings opened per 100 companies and the share of opened postings in each function, by industry, size band or country. The country dimension is the headquarters country of the hiring company, so a United States company hiring in Poland counts under the United States. For demand where the job is, count the country of each posting in Job Postings, which is the country of its first listed location. To split demand by work mode, see the guide to measuring remote and hybrid work trends.

To see who you are competing with, group the same postings by company ID and list the companies with the most postings opened in your function and country. Their rows in Hiring Activity (company_hiring_daily) show whether each has open roles across functions or only in this one, where else it is hiring, and the median advertised pay across its open postings.

A further measure is time advertised. For postings that opened within the observation period and have since closed, the days between the first-seen date and the closed date show how long a role stayed advertised. A posting leaves the board when it is filled or withdrawn. Postings still open are left out, so slow-to-fill roles are understated. Use the measure to compare cuts, not as a time to hire.

Reading the numbers without fooling yourself

Advertised is not paid. A posting states what an employer is prepared to advertise. It shows no negotiated salary, bonus, equity or benefits unless the posting names them, and it says nothing about the person hired.

Employers that state pay are a subset. Only postings that carry a range enter a pay figure, and employers that publish pay may differ from those that do not. Print the share of postings in your cut that state pay beside the range, and treat a low share as a warning.

First location only. A posting with several locations counts once, under the country of the first. A role open in five countries adds to demand in one of them.

Labels are model output. Function and seniority come from a model that leaves a choice empty below 45 percent probability, so a cut covers labelled postings only. Check how many postings your filters drop. Every key of the earlier label version keeps its meaning in the current one, so postings under either version belong to one set.

Repeats and evergreen roles. A company can keep a posting open as a standing pipeline, or close and reopen the same role. Compare postings opened with the change in postings open, and cap each company at a few postings in a benchmark.

Out-of-range pay is blank. A yearly figure below 5,000 or above 2,000,000 US dollars is kept as written but left out of the US-dollar columns. A posting with an unusually low or high figure therefore drops out of the query above, and its stated amount remains in the stated-pay columns.

What the data is built for

The data holds advertised pay and the targets employers state, with a date on every posting, so it answers what offers look like and how they move. Structured on-target earnings, quota and ramp are delivered for sales postings, and for other roles you have the range as stated, in yearly US dollars and as written in the original currency and period. Employer-submitted compensation surveys report what organisations pay, and the two sit well side by side.

Postings are the roles that employers advertise on their own boards, and each carries its job function, seniority, location, work mode and the tools it names. Each posting is dated when first seen and when closed, so a demand or pay cut can be rebuilt as it stood on any past date.

How Fokals delivers it

The hiring dataset holds the postings and the daily table. It is refreshed daily and reaches you through the REST API or as bulk files in CSV or JSON Lines. The weekly pay and demand series sit in the market series dataset, written as of each Sunday. The data dictionary defines every column used here, and the guide to benchmarking sales compensation goes further into the sales quartiles.

Frequently asked questions

Where can I find salary benchmarks by job title and country?

Employer-submitted compensation surveys report what organisations pay. Job postings report what employers advertise for any role they publish, with a date. Fokals holds the second kind: advertised pay ranges from postings on company job boards, by function, seniority and country. Use a survey for pay actually paid, and postings for current offers and how they move.

What does advertised pay show?

A posting states the range an employer is prepared to advertise, and some postings state one. It shows no negotiated salary, bonus, equity or benefits unless the posting names them. Use advertised pay to see where offers start and how they move, and compare it with your own pay data before you set a band.

How many postings do I need for a reliable pay benchmark?

Fokals writes a market pay row only over at least 20 postings, and a sales pay benchmark only for a group of at least five. For a planning band, a floor of 20 postings from at least eight companies is a sensible start. Print both counts beside the range so a reader can judge it.

Can job postings show demand for a skill?

Yes, for the tools a posting names. Each posting records the software it names, matched against a catalogue of about 200 products, and Hiring Activity counts open postings per tool each day. Postings also carry an AI role flag, a technical depth score and the minimum years of experience, so demand for a tool or a level of depth can be counted by function and country.

Can I compare pay across countries?

Pay columns sit on one yearly US-dollar scale, so ranges line up on paper. Exchange rates and living costs differ by country, so a gap is not a gap in purchasing power. Use the US-dollar columns for a rough ordering across countries, and use the stated currency, period and amounts when you compare one country over time.

The queries and code on this page are examples to adapt. Test them in your own environment before you rely on them.