Skip to content

Extracting Skills and Certifications From a Resume

8 min read · updated August 11, 2026

“Skills & Certifications” is one heading on the page and two entities in the data. A skill is an unbounded free-text claim with no issuer and no way to be wrong. A certification is issued by a named body, usually carries a credential number, and frequently expires. Extracting them into the same list is the design error that makes the section useless.

Two data types in one section

The distinction that matters is whether something has an issuing authority. “Distributed systems” does not: nobody awarded it, nobody can revoke it, and there is no register to check it against. “AWS Certified Solutions Architect – Associate” does: it was issued on a date, by a named organisation, under a credential ID that the issuer can verify, and it has a validity period after which the claim is stale.

Everything downstream follows from that split. A skill can only be matched, clustered or scored. A certification can be verified, expired, and counted towards a regulatory requirement. Storing them in one array of strings means the certification loses its issuer, its number and its expiry — the three fields that were the only reason it was worth more than a skill.

A third category hides in the same section and belongs with neither: licences to practise. A nursing licence or a professional engineering licence is not a certification, it is a permission granted by a jurisdiction, and it has a status — active, lapsed, suspended — that lives in a public register rather than on the resume. Those are covered in extracting fields from a professional licence certificate.

A bulleted list and a paragraph are different problems

Skills reach you in two shapes, and they fail differently enough that detecting the shape first is worth the extra branch.

The delimited list is the easy shape and the one with the sharper edges. It looks like Python, Go, Kubernetes, Terraform, PostgreSQL under a heading, and it is essentially already structured. What breaks it is delimiter collision: a comma inside a skill name. Node.js, React splits fine; SAP FI/CO, Excel splits on a slash if you were also splitting on slashes; and a bullet list where one bullet reads Languages: English, French, German is a labelled sub-list that a flat splitter turns into a skill called “Languages: English”. Split on the dominant delimiter only — the one that appears most often at the top level — rather than on a set of them.

The free-text paragraph is the harder shape and needs a genuinely different pass. It reads like “Ten years building event-driven backends, mostly in Go, with substantial exposure to Kubernetes and some Terraform; comfortable owning on-call.” There is no delimiter and there is no list. Extracting from it is span identification: find the mentions, and record for each one whether the text asserts proficiency, exposure or intent. A splitter run over this paragraph returns fragments of English, which is why a single prompt written for both shapes produces good output on one and rubbish on the other.

The paragraph also carries hedging that the list cannot express, and throwing it away misrepresents the candidate in both directions. “some Terraform” and “expert in Terraform” become the same token. Keep an explicit evidence field holding the span the skill was drawn from, and a coarse assertion enum; the alternative is a system that treats a mention as a claim.

Skills also appear implicitly inside role descriptions, which is a different extraction with a different error profile: a technology named in a bullet about what a team used is not necessarily a claim about the candidate. Decide deliberately whether your pipeline mines role text for skills, and if it does, keep those rows separable from the ones the candidate listed themselves.

Normalising against a published taxonomy

Raw skill strings are unusable for matching because the same skill has dozens of surface forms: k8s, K8S, Kubernetes, Kubernetes (EKS), container orchestration. Any useful system maps them onto a controlled vocabulary, and inventing your own vocabulary is a large, permanent maintenance commitment that two published taxonomies already cover.

The European Commission publishes ESCO, the multilingual classification of European Skills, Competences and Occupations, which carries stable identifiers for skills and occupations and translations across EU languages. The US Department of Labor publishes O*NET, which describes occupations in terms of skills, abilities, knowledge areas and tools and technologies. Both are downloadable, both have stable identifiers, and both are versioned — which is the reason to record the taxonomy version on the mapping rather than just the concept id.

Neither will cover a fast-moving technology skill named last quarter. Design for that: keep the raw string alongside the mapped concept, allow the mapped concept to be null, and treat unmapped strings as a queue to review rather than as data to discard. Embedding-based matching is a reasonable first pass for candidate generation here — see embedding classification — but the decision to accept a mapping should be a threshold you set and can move, not a nearest neighbour taken unconditionally, and unmapped strings belong in a routed review queue rather than in the bin.

Certifications are entities

A certification record wants five fields, and four of them are commonly present on the resume:

  • Name, which is almost always abbreviated and often wrongly. PMP, CISSP, CPA, CFA Level II — and the level is part of the claim, not decoration.
  • Issuer, which is frequently omitted because the candidate assumes it is obvious. A vendor certification implies its vendor; a professional one does not always imply its body.
  • Credential ID, present when the candidate has copied it from their digital badge and absent otherwise. It is the field that makes verification possible.
  • Issue date, usually a month and year.
  • Expiry, which is the field that changes the record over time and the one most often missing from a schema entirely.

Expiry deserves the same treatment it gets on a licence: never derive it from a rule you believe about the issuer, because renewal terms change and vary by product. Extract it if it is printed, leave it null if it is not, and record an expiry_source of stated or unknown so a downstream “expired” decision can never be based on an assumption you made once.

Putting it together

{
  "skills": [
    { "raw": "k8s", "concept": "esco:S1.2.3", "taxonomy_version": "1.2.0",
      "source": "list", "assertion": "claimed" },
    { "raw": "some Terraform", "concept": null, "taxonomy_version": null,
      "source": "paragraph", "assertion": "exposure",
      "evidence": "with substantial exposure to Kubernetes and some Terraform" }
  ],
  "certifications": [
    { "name": "AWS Certified Solutions Architect - Associate",
      "issuer": "Amazon Web Services", "credential_id": "SYNTHETIC-0000-0000",
      "issued": "2024-03", "expires": "2027-03", "expiry_source": "stated" },
    { "name": "PMP", "issuer": null, "credential_id": null,
      "issued": "2021-09", "expires": null, "expiry_source": "unknown" }
  ]
}

Two things in that record are doing quiet work. taxonomy_version means a mapping made against last year’s ESCO release is identifiable as such when the release changes, rather than becoming an unexplained inconsistency. And source on each skill records whether it came from a list the candidate wrote or from prose you interpreted, which is the difference between a claim and an inference — a distinction that anyone auditing a hiring pipeline will ask about, and that costs one enum to preserve.