ResumeJSON

Extract education from a resume: the rules, the code, and the traps

To extract education from a resume you need four things per entry: the institution, the degree, the field of study and the dates. You can get them two ways. Either you find the education section yourself and pull the fields out with patterns, or you send the whole document to a parser that returns an education array already split into those fields. This article walks through both, with code, and names the CVs that break each one.

We build ResumeJSON, a resume parsing API, so read the second half as written by an interested party. The first half needs nothing from us.

What "education" should come out as

Before any code, decide the shape. A shape you pick after the first hundred CVs is a migration. This is the one that survives real documents:

FieldExampleWhy it is its own field
institutionUniversity of LeedsDeduplicating schools is a separate job, and it needs the raw name
degreeBScFilters such as "has a master's" read this, never the subject
field_of_studyComputer ScienceMatching a role to a subject reads this, never the degree
start_date2014-09Often missing, so it must be allowed to be empty
end_date2017-06The date most filters actually use

Every field must be allowed to be empty. A CV that says "Leeds, 2017" has no degree on the page, and a pipeline that fills the gap with a guess turns a missing fact into a wrong one. Dates are worth storing as YYYY-MM, or YYYY when only a year is written, rather than as a full date, because nobody writes the day they graduated.

Keep degree and field apart. "BSc Computer Science" and "Bachelor of Science in Computer Science" are the same two facts. Stored as one string, they can never be compared.

Step 1: find the education section

Every section-based extractor starts the same way. Split the text into lines, find a heading that means education, and read until the next heading.

import re

EDU_HEADINGS = re.compile(
    r"^\s*(education|academic background|qualifications|academic history|studies)\s*:?\s*$",
    re.IGNORECASE,
)
ANY_HEADING = re.compile(
    r"^\s*(experience|work experience|employment|skills|projects|certifications|"
    r"languages|interests|references|summary|profile)\s*:?\s*$",
    re.IGNORECASE,
)

def education_block(text: str) -> list[str]:
    lines = [l.strip() for l in text.splitlines()]
    out, inside = [], False
    for line in lines:
        if EDU_HEADINGS.match(line):
            inside = True
            continue
        if inside and ANY_HEADING.match(line):
            break
        if inside and line:
            out.append(line)
    return out

This works on a tidy single-column CV. The text has to come from somewhere first: Python resume parser: the libraries that work covers getting clean text out of PDF and DOCX, and OCR resume parser covers the scanned ones.

Step 2: pull the fields out with patterns

Inside the block, three patterns do most of the work: a degree vocabulary, a year range, and "the line that looks like a school".

DEGREE = re.compile(
    r"\b(B\.?Sc|BA|B\.?A\.?|BEng|B\.?Tech|M\.?Sc|MA|MBA|MEng|M\.?Tech|Ph\.?D|"
    r"Bachelor(?:'s)?|Master(?:'s)?|Doctorate|Associate(?:'s)?|Diploma)\b",
    re.IGNORECASE,
)
YEARS = re.compile(r"\b((?:19|20)\d{2})\s*(?:-|to|–)\s*((?:19|20)\d{2}|present)\b", re.IGNORECASE)
SINGLE_YEAR = re.compile(r"\b((?:19|20)\d{2})\b")
SCHOOL = re.compile(r"\b(university|college|institute|school|academy|polytechnic)\b", re.IGNORECASE)

def parse_entries(block: list[str]) -> list[dict]:
    entries, cur = [], None
    for line in block:
        if SCHOOL.search(line):
            if cur:
                entries.append(cur)
            cur = {"institution": line, "degree": None, "field_of_study": None,
                   "start_date": None, "end_date": None}
        if cur is None:
            continue
        m = DEGREE.search(line)
        if m and cur["degree"] is None:
            cur["degree"] = m.group(0)
            rest = line[m.end():].strip(" ,:-")
            rest = re.sub(r"^(in|of)\s+", "", rest, flags=re.IGNORECASE)
            cur["field_of_study"] = rest or None
        y = YEARS.search(line)
        if y:
            cur["start_date"], cur["end_date"] = y.group(1), y.group(2)
        elif cur["end_date"] is None and (s := SINGLE_YEAR.search(line)):
            cur["end_date"] = s.group(1)
    if cur:
        entries.append(cur)
    return entries

On this input:

Education
University of Leeds
BSc in Computer Science, 2014 - 2017

it returns one entry with the institution, BSc, Computer Science and both years. That is the demo. The next section is the rest of the work.

Where the pattern approach breaks

Run it over a real folder and the failures cluster. Each one is a rule you will end up writing:

The last one is the dangerous failure because it looks like an answer. An empty education list from an extractor that missed the section is indistinguishable from a candidate with no degree. If you filter on education, count how often the block is empty and read a sample of those CVs by hand before trusting the filter.

When the manual way is enough

The pattern route is worth keeping when all of these hold:

Under those conditions the fifty lines above, plus a longer degree list, are the honest answer. Nothing leaves your machine and nothing costs money.

Extract education from a resume with an API

Once the CVs come from strangers, in every layout, the section finder and the degree list become the whole project. A parsing API moves that work to the other side of one call and hands back the education entries already split.

Here is the same job against ResumeJSON, which returns typed JSON in about two seconds:

import requests

resp = requests.post(
    "https://resumejson-resume-cv-parser-api.p.rapidapi.com/v1/parse",
    headers={
        "x-rapidapi-key": "YOUR_KEY",
        "x-rapidapi-host": "resumejson-resume-cv-parser-api.p.rapidapi.com",
        "content-type": "application/json",
    },
    json={"text": text},          # or send the file: base64 or multipart
    timeout=30,
)
for entry in resp.json()["resume"]["education"]:
    print(entry["institution"], entry["degree"], entry["field_of_study"], entry["end_date"])

Each entry carries exactly the five fields from the table at the top: institution, degree, field_of_study, start_date and end_date. Any of them is null when the document does not state it, and dates come back as YYYY-MM, or YYYY when only a year is written. The rules the pattern route has to learn one CV at a time are part of the parse:

A CV written in one language can come back in another: ?output_language=English on an Indonesian CV returns the subject as the English word for it, while the institution keeps its own name.

The two routes, side by side

Section finder and patternsResumeJSON
SetupYour code, your degree listOne HTTP call
Unusual layoutsA new rule per layoutHandled in the parse
Local degree namesOnly those on your listRead from the document
Certifications under EducationCounted as degrees unless you filterReturned in certifications
Missing sectionEmpty list, looks like "no degree"Entries found wherever they are
Cost at 100 CVs/monthYour time$0, the free rung is a hard cap
Cost at 1,000 CVs/monthYour time$29/month, or $0.05 a parse pay-per-use
Runs offlineYesNo

The honest gap is normalisation. We return the degree as the CV writes it: BSc stays BSc and Bachelor of Science stays Bachelor of Science. If your filter needs both to land on one level such as "bachelor's", map the strings yourself with a short lookup table. That table is far smaller than the extractor it replaces, because the strings arrive already isolated.

Using the result

With education as data, the useful checks are one line each:

def has_degree(resume, levels=("bsc", "ba", "bachelor", "msc", "ma", "master", "mba", "phd")):
    return any(
        e["degree"] and any(l in e["degree"].lower() for l in levels)
        for e in resume["education"]
    )

def graduated_after(resume, year: int):
    return any(e["end_date"] and int(e["end_date"][:4]) >= year for e in resume["education"])

Treat both as routing rather than rejection: a CV that fails has_degree goes to a person, because an empty field can still mean a CV nobody could read well. Automated resume screening builds a whole screen on that principle, and compare resume and job description shows how to hold the education entries against a role's stated requirement. For a whole backlog rather than one file, bulk resume parsing covers concurrency and retries, and resume to Excel puts the entries in a spreadsheet.

How to choose

You can see the output before deciding anything: the free browser parser runs a real CV through the same endpoint with no signup, and the field reference lists every education field straight from the API's own schema.

All articles