Skip to content

Extracting Employment Gaps From a Resume's Date Ranges

9 min read · updated August 11, 2026

Given a list of start and end dates, finding the gaps looks like sorting and subtracting. It is not. Roles overlap, and the moment they do, a scan that compares each role’s end to the next role’s start reports gaps that never existed.

It is not subtraction, it is interval merging

A career is a set of intervals on a timeline, and they are not disjoint. A contract taken alongside a permanent job, a notice period that overlaps the new start date, an advisory role held throughout — all of these produce overlapping ranges on a truthful CV. The question “when was this person not working” is the complement of the union of the intervals, and computing a union requires merging.

The naive algorithm sorts roles by start date, then walks the list comparing the previous role’s end to the current role’s start. It is correct on disjoint intervals and wrong the instant a short role sits entirely inside a long one, because after processing the short role the “previous end” has moved backwards. Every subsequent comparison is against a date earlier than the person’s actual last day, and the next real gap is reported as longer than it is.

The fix is one Math.max, which is why this bug survives review: the code looks right, and it produces plausible output on the majority of CVs that happen not to contain an overlap.

The month-rounding rule, stated

Most CV dates have month precision, so the arithmetic should be done in whole months and the rule has to be written down, because two reasonable people will pick different conventions and get answers that differ by two months.

  • A month-precision range is inclusive at both ends. “Mar 2019 – Jul 2021” means the person worked in March 2019 and in July 2021, and every month between.
  • A gap is the count of whole months in neither interval. If one role ends in July and the next starts in August, the gap is zero, not one. If the next starts in September, the gap is one month — August.
  • Adjacent intervals merge. Two ranges where one ends in the month before the other begins form a single continuous interval with no gap between them.
  • A year-precision date is expanded conservatively. “2019” as a start becomes January 2019 and as an end becomes December 2019, because that is the interpretation that does not invent a gap. State this, because the opposite convention is also defensible and produces different numbers.
  • An open end is closed at the extraction date, and that date is recorded on the result. Otherwise the same resume produces different gaps depending on when it was processed and nothing in the output explains why.

Represent a month as a single integer — year * 12 + (month - 1) — and every one of these rules becomes an integer comparison. Doing this arithmetic on date objects invites a timezone to change an answer, which on a month-precision input is absurd but happens.

The same resume, two answers

Four roles, of which one is a short contract that sits inside a longer permanent role:

Acme Freight    Engineer            Mar 2019 - Jul 2021
Self-employed   Contract (advisory) Jan 2021 - Apr 2021
Borealis        Platform Engineer   Apr 2022 - Feb 2024
Borealis        Senior Platform Eng Feb 2024 - Present     (extracted 2026-08)

As month indices, with m = year * 12 + (month - 1):

Acme      Mar 2019 -> 24230    Jul 2021 -> 24258
Contract  Jan 2021 -> 24252    Apr 2021 -> 24255
Borealis  Apr 2022 -> 24267    Feb 2024 -> 24289
Borealis  Feb 2024 -> 24289    Aug 2026 -> 24319   (open end, closed at extraction)

NAIVE running-end scan, sorted by start:
  prev_end = 24258 (Acme)
  Contract starts 24252 -> 24252 - 24258 - 1 = -7, clamped to 0
  prev_end = 24255            <-- the bug: it moved backwards
  Borealis starts 24267 -> 24267 - 24255 - 1 = 11 months

CORRECT interval merge:
  sorted:  [24230,24258] [24252,24255] [24267,24289] [24289,24319]
  merge 1: 24252 <= 24258 + 1  ->  [24230, max(24258,24255)] = [24230,24258]
  merge 2: 24267 >  24258 + 1  ->  new interval
  merge 3: 24289 <= 24289 + 1  ->  [24267, 24319]
  merged:  [24230,24258] [24267,24319]
  gap:     24267 - 24258 - 1 = 8 months  (Aug 2021 - Mar 2022)

Eleven against eight, from the same input, on a resume with nothing unusual on it. The three phantom months are exactly the months between the contract ending in April 2021 and the permanent role ending in July 2021 — months the person was demonstrably employed, counted as a gap because a variable moved backwards.

The implementation

  1. Extract the roles with their dates and precisions first, as a separate step. This page assumes the schema from extracting work history from a CV, where an open end is null rather than today’s date.
  2. Drop any role whose start is missing or whose start is after its end. A reversed range is an extraction error — the ordering assertion belongs in a date field validation rule upstream of this — and feeding it into a merge produces a negative interval that silently swallows real gaps.
  3. Convert each range to a pair of month indices, applying the year-precision expansion and closing open ends at the extraction date.
  4. Sort by start index, then merge.
  5. Take the complement between consecutive merged intervals. Report each gap with its start month, end month and length.
const mi = (y, m) => y * 12 + (m - 1);
const fmt = (i) => `${Math.floor(i / 12)}-${String((i % 12) + 1).padStart(2, "0")}`;

function gaps(roles, asOf) {
  const iv = roles
    .filter((r) => r.startIndex != null && r.startIndex <= (r.endIndex ?? asOf))
    .map((r) => [r.startIndex, r.endIndex ?? asOf])
    .sort((a, b) => a[0] - b[0]);

  const merged = [];
  for (const [s, e] of iv) {
    const last = merged[merged.length - 1];
    // <= last[1] + 1 because adjacent months are continuous, not a gap.
    if (last && s <= last[1] + 1) last[1] = Math.max(last[1], e);
    else merged.push([s, e]);
  }

  const out = [];
  for (let i = 1; i < merged.length; i++) {
    const months = merged[i][0] - merged[i - 1][1] - 1;
    if (months > 0) {
      out.push({
        from: fmt(merged[i - 1][1] + 1),
        to: fmt(merged[i][0] - 1),
        months,
      });
    }
  }
  return { as_of: fmt(asOf), gaps: out };
}

Two lines carry the correctness. Math.max(last[1], e) is the fix for the backwards-moving end. And s <= last[1] + 1 rather than s <= last[1] is what makes July-then-August continuous instead of a one-month gap, which is the second most common way this calculation is wrong.

The as_of field on the result is not decoration. A gap list computed against an open-ended current role is only meaningful with the date it was computed on, and a stored result without it becomes unreproducible the following month.

What a gap does and does not mean

The arithmetic is exact; its interpretation is not, and the difference is worth building into the output rather than leaving to whoever reads it.

A gap in a document is the absence of a stated role, not the presence of unemployment. Study, caring responsibilities, illness, parental leave, military service, travel and self-employment that the author did not think worth listing all produce identical output. So does a CV that simply omits early jobs, which is why a “gap” before the earliest listed role should never be emitted at all — only intervals bounded by roles on both sides are gaps.

Several of the reasons in that list correlate with protected characteristics, which makes an automated gap flag a feature with discrimination risk attached rather than a neutral metric. Where such a signal feeds a decision about a person, the governing question is whether the decision is solely automated and what involvement a human actually has — ground covered in meaningful human involvement under Article 22. That is a design question to answer before the feature ships, not legal advice, and it is the reason to emit gaps as descriptive intervals with their evidence rather than as a score.

Two output habits keep the result honest. Report each gap with its bounding roles attached, so a reader sees the interval in context. And set a floor — a one- or two-month gap is a notice period, and reporting it as a finding tells you the threshold is wrong rather than that the candidate is.