Skip to content

Convert a PDF or DOCX Resume to the JSON Resume Format

Convert a PDF or DOCX resume to JSON Resume 1.7.0: key facts, a field-by-field mapping, a tested Python converter and validation with resume-cli.

Published · 6 minutes read

Cover reading “Convert a resume to JSON Resume” with a document icon and a code icon

To convert a PDF or DOCX resume to the JSON Resume format, parse the file into structured fields, map those fields onto the JSON Resume schema, and validate the result against the official schema.json. JSON Resume is an open standard that describes a resume as one JSON document with sections such as basics, work, education, skills and languages. Here the parsing is one POST /api/v3/parser request to HireLayer CV Extract, authenticated with an X-API-Key header, and a Python script of about 110 lines does the mapping and the validation.

Key factsValue
Schema versionJSON Resume 1.7.0, a Draft-07 JSON Schema (npm: @jsonresume/schema)
Schema filepackages/schema/schema.json in the jsonresume/jsonresume.org repo (the old resume-schema repo is archived)
FormatEvery field optional, no null; ISO 8601 dates such as 2023, 2023-04 or 2023-04-29
Validationresume validate (resume-cli, Node.js 18+) or Python jsonschema with a FormatChecker
Renderingresume serve and resume export (HTML, PDF), with 67 community themes on npm
HireLayer endpointPOST https://hirelayer.co/api/v3/parser, multipart file, 1 credit per successful parse (Free plan: 50 credits a month)
Docsjsonresume.org/schema, CV Extract reference (checked November 5, 2026)

1. Parse the PDF or DOCX

The hard part isn't the JSON, it's reading the document: columns, tables, scans, dates written ten different ways. CV Extract takes one file per request (PDF, DOC, DOCX, ODT, RTF, TXT, HTML, Markdown, JPG, PNG and more, up to about 4.5 MB) and returns fixed fields in the same response.

Parsing usually takes about 35 seconds, longer for scans, and the API answers 504 after 145 seconds. Keep the client timeout at 150 seconds or more, and retry only the 5xx errors. Failed calls aren't charged.

This first block holds the imports and the API call:

import json
import os
import sys
import time
from pathlib import Path
from urllib.parse import urlparse

import requests
from jsonschema import Draft7Validator, FormatChecker

API_URL = "https://hirelayer.co/api/v3/parser"
SCHEMA_URL = (
    "https://raw.githubusercontent.com/jsonresume/jsonresume.org"
    "/master/packages/schema/schema.json"
)


def parse_resume(path: Path, attempts: int = 4) -> dict:
    """POST the PDF or DOCX to CV Extract V3, retrying 5xx errors."""
    for attempt in range(1, attempts + 1):
        with path.open("rb") as handle:
            response = requests.post(
                API_URL,
                headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
                files={"file": (path.name, handle)},
                timeout=150,  # the API answers 504 after 145 s
            )
        if response.status_code == 200:
            return response.json()
        if response.status_code < 500 or attempt == attempts:
            request_id = response.headers.get("x-parser-request-id")
            raise RuntimeError(
                f"CV Extract {response.status_code} ({request_id}): {response.text[:300]}"
            )
        time.sleep(2**attempt)

A 4xx (bad file, no credits, 422 not a resume) is raised right away with the x-parser-request-id, as the errors guide recommends. Want to see the raw response first? Drop a file into the free online resume parser.

2. Map the fields

Left: the CV Extract V3 field, as named in the API reference. Right: where it lands in JSON Resume 1.7.0.

CV Extract V3JSON ResumeNote
info_candidate.full_name, job_titlebasics.name, basics.labelAs written in the resume.
info_candidate.email, phone_numberbasics.email, basics.phonePhone in E.164 when valid.
info_resume.face_urlbasics.imageOnly when face_url_expires_at is null.
info_candidate.other_urls[0]basics.urlhttps:// added when missing.
info_candidate.location: full_address, postal_code, city, country_code , regionbasics.location: address, postalCode, city, countryCode, regionISO 3166-1 alpha-2 on both sides.
linkedin_url, github_urlbasics.profiles[]network, username, full url.
work_experiences[]: company_name, job_title, description, city + countrywork[]: name, position, summary, locationLocation as free text, e.g. London, United Kingdom.
start_date, end_date, currently_activestartDate, endDateendDate omitted for a current entry.
educations[]: school_name, degree_title, descriptioneducation[]: institution, studyType, summarySame date rule as work.
skills[]: skill_type, skill_titleskills[]: name, keywordsOne group per skill type.
languages[]: language, levellanguages[]: language, fluencye.g. Professional Working Proficiency (B2).
certifications[], interests[]certificates[].name, interests[].namePlain strings in the API.
info_resume.date_parsingmeta.lastModifiedYYYY-MM-DDThh:mm:ss

Nothing in the CV Extract response feeds basics.summary, objective, projects, awards, publications or references, so those sections stay out. In practice, you can add them by hand afterwards.

3. Write the converter

The mapping function follows the table line by line. Skills are grouped by skill_type because themes render skills[] as a name plus a list of keywords, and the photo is skipped when its link expires:

def to_json_resume(parsed: dict) -> dict:
    """Map a CV Extract V3 response onto JSON Resume 1.7.0."""
    person, info = parsed["info_candidate"], parsed["info_resume"]
    loc = person["location"]
    skills: dict[str, list[str]] = {}
    for skill in parsed["skills"]:  # themes render skills grouped under a name
        skills.setdefault(skill["skill_type"], []).append(skill["skill_title"])
    return clean({
        "$schema": SCHEMA_URL,
        "basics": {
            "name": person["full_name"],
            "label": person["job_title"],
            # a face_url with an expiry (do_not_store_data=true) dies after 10 minutes
            "image": None if info["face_url_expires_at"] else info["face_url"],
            "email": person["email"], "phone": person["phone_number"],
            "url": as_url((person["other_urls"] or [None])[0]),
            "location": {"address": loc["full_address"], "postalCode": loc["postal_code"],
                         "city": loc["city"], "countryCode": loc["country_code"],
                         "region": loc["region"]},
            "profiles": [profile("LinkedIn", person["linkedin_url"]),
                         profile("GitHub", person["github_url"])],
        },
        "work": [{"name": job["company_name"], "position": job["job_title"],
                  "location": place(job["work_experience_city"],
                                    job["work_experience_country"]),
                  "startDate": job["start_date"], "summary": job["description"],
                  "endDate": None if job["currently_active"] else job["end_date"]}
                 for job in parsed["work_experiences"]],
        "education": [{"institution": edu["school_name"], "studyType": edu["degree_title"],
                       "startDate": edu["start_date"], "summary": edu["description"],
                       "endDate": None if edu["currently_active"] else edu["end_date"]}
                      for edu in parsed["educations"]],
        "skills": [{"name": name, "keywords": list(dict.fromkeys(words))}
                   for name, words in skills.items()],
        "languages": [{"language": lang["language"], "fluency": lang["level"]}
                      for lang in parsed["languages"]],
        "certificates": [{"name": name} for name in parsed["certifications"]],
        "interests": [{"name": name} for name in parsed["interests"]],
        "meta": {"lastModified": info["date_parsing"]},
    })

The helpers and the entry point finish the script. clean() runs last because most CV Extract fields are null when the resume doesn't mention them, and JSON Resume rejects a null where it expects a string:

def clean(value):
    """Drop None and empty values, recursively: JSON Resume rejects null."""
    if isinstance(value, dict):
        value = {k: clean(v) for k, v in value.items()}
        return {k: v for k, v in value.items() if v not in (None, "", [], {})}
    if isinstance(value, list):
        return [v for v in map(clean, value) if v not in (None, "", [], {})]
    return value


def as_url(value: str | None) -> str | None:
    """Resumes write "github.com/name", but the schema wants a full URI."""
    return value if not value or "://" in value else f"https://{value}"


def place(city: str | None, country: str | None) -> str | None:
    return ", ".join(part for part in (city, country) if part) or None


def profile(network: str, url: str | None) -> dict | None:
    url = as_url(url)
    if url:
        username = urlparse(url).path.rstrip("/").split("/")[-1] or None
        return {"network": network, "username": username, "url": url}


def main() -> None:
    parsed = parse_resume(Path(sys.argv[1]))
    resume = to_json_resume(parsed)
    schema = requests.get(SCHEMA_URL, timeout=30).json()
    validator = Draft7Validator(schema, format_checker=FormatChecker())
    errors = [f"{e.json_path}: {e.message}" for e in validator.iter_errors(resume)]
    if errors:
        sys.exit("Invalid JSON Resume:\n" + "\n".join(errors))
    json.dump(resume, sys.stdout, indent=2, ensure_ascii=False)


if __name__ == "__main__":
    main()

Honestly, as_url() only exists because of the first test run. The fictional resume wrote github.com/… and linkedin.com/in/…, the API returned them as written, and the schema rejected both as not being a uri. Wich is exactly the kind of thing validation is there to catch.

4. Validate and render

Install the dependencies, run the script, then check the file with the official CLI. The format-nongpl extra matters: without it, jsonschema silently skips the uri checks.

python -m pip install requests "jsonschema[format-nongpl]"
export HIRELAYER_API_KEY="your-key-from-the-dashboard"
python to_json_resume.py resume.pdf > resume.json

npm install -g resume-cli
npm install jsonresume-theme-elegant
resume validate --resume resume.json
resume serve --resume resume.json --theme elegant
resume export resume.pdf --resume resume.json --theme elegant

resume validate exits non-zero on errors, so it fits in CI. From Node.js, the @jsonresume/schema package exposes the same check as a validate function. resume serve previews the file locally, and a short theme name like elegant loads the jsonresume-theme-elegant package. Here is a trimmed ouput from that test resume:

{
  "basics": {
    "name": "Daniel OKAFOR",
    "label": "Senior Backend Engineer",
    "phone": "+447700900418",
    "profiles": [
      { "network": "GitHub", "username": "dokafor-dev", "url": "https://github.com/dokafor-dev" }
    ]
  },
  "work": [
    { "name": "Deliveroo", "position": "Senior Backend Engineer", "startDate": "2022-01-01" }
  ],
  "languages": [{ "language": "French", "fluency": "Limited Working Proficiency (B1)" }]
}

For a batch, store the CV Extract JSON next to each resume.json so you can re-map it later without paying for a second parse, and vendor a copy of schema.json in your repo so a schema change never breaks a deploy. The resume PDF to JSON API guide covers the raw parser output in more detail.

Fields that do not map 1:1

  • Dates written as a year. CV Extract always returns YYYY-MM-DD: "2015" becomes 2015-01-01 as a start and 2015-12-31 as an end. Trim them to the year if your theme shows full dates.
  • Degrees. The full degree_title goes into studyType; splitting out the area reliably across languages is guesswork.
  • Skills. There's no level per skill in the response, so skills[].level stays empty.
  • Volunteering. It arrives in work_experiences with contract_type Volunteering. Move those entries to volunteer[] and rename name to organization if you need the seperate section.
  • The photo. With do_not_store_data=true (Enterprise plan), face_url is a temporary link that expires 10 minutes after parsing. Download the image first if you want it in basics.image.
  • Last names in capitals. CV Extract capitalizes the first name and writes the last name in capitals, so the name reads "Daniel OKAFOR". Title-case the last name with care (think "McDonald") if your theme expects natural case.
  • Personal data. birth_date, driver_license, mobility and the full resume text are left out on purpose, since a JSON Resume often gets published. Put what you need under a namespaced custom key.

Frequently asked questions

What is the JSON Resume format?

An open, MIT-licensed standard that stores a resume as one JSON document, defined by a Draft-07 JSON Schema (version 1.7.0 on November 5, 2026). Every field is optional.

How do I convert my resume to JSON Resume?

Parse the PDF or DOCX with a resume parser API, map the fields to the schema, drop null values, then run resume validate. The Python script above does it in one command.

Can I convert a LinkedIn PDF export to JSON Resume?

Yes. A LinkedIn profile saved as PDF is a regular PDF, so the same script works unchanged.

How much does it cost to convert a resume?

The JSON Resume tools are free and open source. The parsing call costs 1 HireLayer credit per successful parse, and the Free plan includes 50 credits a month.

Check the fields on your own resume

Upload a PDF or DOCX in the live demo to see the raw CV Extract JSON, then follow the API reference for the full response schema.

Sources and further reading

  1. JSON Resume: home page and getting started
  2. JSON Resume: schema reference (version 1.7.0)
  3. JSON Resume: resume-cli commands and options
  4. jsonresume/resume-schema on GitHub (archived, moved notice)
  5. jsonresume/jsonresume.org: packages/schema (schema.json, README)
  6. HireLayer API documentation: CV Extract (V3)
  7. HireLayer API documentation: Errors and retries
  8. jsonschema (Python): validating formats

Louis Desclous

Published on · Reading time: 6 minutes