Extract education from a resume: the rules, the code, and the traps
Published
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:
| Field | Example | Why it is its own field |
|---|---|---|
institution | University of Leeds | Deduplicating schools is a separate job, and it needs the raw name |
degree | BSc | Filters such as "has a master's" read this, never the subject |
field_of_study | Computer Science | Matching a role to a subject reads this, never the degree |
start_date | 2014-09 | Often missing, so it must be allowed to be empty |
end_date | 2017-06 | The 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 outThis 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 entriesOn this input:
Education
University of Leeds
BSc in Computer Science, 2014 - 2017it 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:
- Two-column layouts. PDF text extraction reads across columns, so the education lines arrive interleaved with the skills sidebar. The block from Step 1 contains "Python" between the school and the degree.
- Degree and school on one line. "MSc Data Science, Imperial College London, 2019" is a common layout. The
SCHOOLrule starts the entry, and thefield_of_studycapture swallows the school name too. - Spelled-out and local degrees. "Bachelor of Engineering", "Licenciatura", "S.Kom", "Diplom-Ingenieur", "HND". A degree list is never finished.
- Schools without a school word. "ETH Zurich", "Sciences Po", "MIT". The
SCHOOLpattern never fires, so the entry is lost. - Certifications under an education heading. "AWS Certified Solutions Architect" and a bootcamp both sit in many education sections. Treating them as degrees pollutes every "has a degree" filter.
- No heading at all. One-page CVs sometimes list the degree under the name with no section. Step 1 returns an empty block, and the candidate reads as uneducated.
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:
- the CVs come from one template, such as your own application form exported to PDF;
- you need one field, say "has any degree", not the full record;
- a person reviews every result anyway, so a miss costs a glance rather than a rejected candidate.
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:
- Certifications are kept out. A certification, licence, bootcamp or online course goes to a separate
certificationsarray, even when the CV lists it under Education. - The layout does not decide the result. Two-column PDFs, DOCX files, and scanned or photographed CVs go down the same endpoint.
- Nothing is invented. A degree the CV does not name is
null, never a guess from the school. - The rest of the record comes too.
work,skills,languagesandtotal_years_experienceare in the same response, so education is never a separate project.
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 patterns | ResumeJSON | |
|---|---|---|
| Setup | Your code, your degree list | One HTTP call |
| Unusual layouts | A new rule per layout | Handled in the parse |
| Local degree names | Only those on your list | Read from the document |
| Certifications under Education | Counted as degrees unless you filter | Returned in certifications |
| Missing section | Empty list, looks like "no degree" | Entries found wherever they are |
| Cost at 100 CVs/month | Your time | $0, the free rung is a hard cap |
| Cost at 1,000 CVs/month | Your time | $29/month, or $0.05 a parse pay-per-use |
| Runs offline | Yes | No |
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
- One template, one field, a person checking → the section finder and patterns above.
- CVs from anyone, and you need the full education record → a parsing API.
- Degrees normalised to a fixed list of levels → either route, plus your own lookup table on top.
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.