DEV Community

LiraelVex6403
LiraelVex6403

Posted on

Python PDF Resume Parsing 2026: Extract Text into Structured Model Fields

TL;DR: Parse each PDF resume into text, ask a model for a strict record, validate that record, redact personal data, and only then write approved content to a database or vector index. Keep the extracted text. For a marketplace processing batches, this separation matters more than the particular PDF parser or model: it makes bad output diagnosable, permits prompt changes without another parse, and puts a testable privacy boundary before sharing or retrieval.

I would reject a design that sends a resume straight from upload to a model and treats the returned JSON as a database row. It may look efficient in a sequence diagram, yet it combines extraction errors, model errors, and disclosure errors into one opaque result. The capacity plan is also wrong: an expensive parse becomes inseparable from a comparatively cheap re-structure when a prompt or schema changes.

Consider a bounded production incident, without inventing a benchmark: a marketplace receives a batch of resumes, a downstream reviewer should see skills and employment history but not direct contact details, and one output fails validation. With a retained text artifact, the operator can ask whether the PDF parser lost a column, whether the model inferred a field that was absent, or whether the redaction policy rejected a value. Without it, the only recovery is to replay the whole document and hope the new result explains the old one.

It will not.

How should Python parse a PDF resume and extract text?

Use a PDF adapter to extract text, pass that text to a model under a strict output contract, validate its fields, and create a separate redacted value before any database or search write. Python libraries can implement those interfaces just as readily as the Go example below; the invariant is a staged contract: immutable source, retained extracted text, schema-validated structured data, policy-approved redacted data, then persistence and search. A vendor can change behind one stage without changing the record passed to the next. That is the useful meaning of portability here; pretending all parsers produce equivalent text, or all models interpret a resume identically, would be careless.

The record needs provenance as well as fields. Store a source document identifier, a content digest, parser and prompt configuration identifiers, validation status, and the accepted result. Those are design recommendations, not claims about a vendor response shape. They let a worker deduplicate at-least-once delivery, re-run only the structuring stage, and keep unvalidated data out of the serving path.

For batch throughput, capacity each stage separately. If arrival rate is lambda documents per second and a worker sustains mu, the starting concurrency estimate is ceil(lambda / mu) before headroom; actual limits must come from a load test with representative PDFs, because scanned pages and text PDFs do not consume the same work. Define an SLO around the result users receive, such as batch completion and the proportion of records that pass both schema and redaction policy, rather than celebrating request acceptance.

The queue boundary belongs after durable source storage and before parsing. Keep rejected records in a reviewable state rather than silently indexing partial output. Backpressure is a feature.

The preventative path in Go

The core path below is deliberately vendor-neutral. It demonstrates the part teams too often omit: strict decoding rejects unknown keys, validation refuses optimistic model shapes, redaction produces a different type, and the write interface accepts only that redacted type. ParsePDF and Structure are interfaces because their wire payloads must follow the selected provider's current schema, not fields guessed in an article.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Candidate struct {
    Name       string   `json:"name"`
    Email      string   `json:"email"`
    Phone      string   `json:"phone"`
    Skills     []string `json:"skills"`
    Experience []string `json:"experience"`
}

type SharedCandidate struct {
    Skills     []string `json:"skills"`
    Experience []string `json:"experience"`
}

type PDFParser interface {
    ParsePDF(context.Context, []byte) (string, error)
}

type StructuringModel interface {
    Structure(context.Context, string) ([]byte, error)
}

type TextStore interface {
    PutExtractedText(context.Context, string, string) error
}

type SearchIndex interface {
    Upsert(context.Context, string, SharedCandidate) error
}

func postInfrai(ctx context.Context, path string, body []byte) ([]byte, error) {
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    key := os.Getenv("INFRAI_API_KEY")
    if baseURL == "" || key == "" {
        return nil, errors.New("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("POST %s returned %d: %s", path, resp.StatusCode, responseBody)
        }
        return responseBody, nil
    }
    return nil, errors.New("rate-limit retry budget exhausted")
}

func parseWithInfrai(ctx context.Context, requestJSON []byte) ([]byte, error) {
    return postInfrai(ctx, "/v1/pdf/parse", requestJSON)
}

func upsertWithInfrai(ctx context.Context, requestJSON []byte) ([]byte, error) {
    return postInfrai(ctx, "/v1/vector/upsert", requestJSON)
}

func decodeStrict(raw []byte) (Candidate, error) {
    dec := json.NewDecoder(bytes.NewReader(raw))
    dec.DisallowUnknownFields()

    var c Candidate
    if err := dec.Decode(&c); err != nil {
        return Candidate{}, fmt.Errorf("decode model output: %w", err)
    }
    if strings.TrimSpace(c.Name) == "" || len(c.Skills) == 0 {
        return Candidate{}, errors.New("invalid candidate: name and skills are required")
    }
    return c, nil
}

func redact(c Candidate) SharedCandidate {
    return SharedCandidate{Skills: c.Skills, Experience: c.Experience}
}

func process(ctx context.Context, id string, pdf []byte, parser PDFParser, model StructuringModel, raw TextStore, index SearchIndex) error {
    text, err := parser.ParsePDF(ctx, pdf)
    if err != nil {
        return fmt.Errorf("parse PDF: %w", err)
    }
    if err := raw.PutExtractedText(ctx, id, text); err != nil {
        return fmt.Errorf("retain extracted text: %w", err)
    }

    modelJSON, err := model.Structure(ctx, text)
    if err != nil {
        return fmt.Errorf("structure text: %w", err)
    }
    candidate, err := decodeStrict(modelJSON)
    if err != nil {
        return err
    }
    return index.Upsert(ctx, id, redact(candidate))
}

func main() {
    fmt.Println("generate request types from discovery, then wire the adapters into process")
}
Enter fullscreen mode Exit fullscreen mode

The model prompt should say that missing facts remain missing and should request exactly the fields the validator accepts. In Python, the same boundary maps naturally to a PDF adapter, a strict model such as Pydantic, and a separate redacted output model; changing languages does not change the failure domains.

Notice what is absent: there is no database write before validation, no contact field in SharedCandidate, and no retry loop around an unkeyed side effect. A production worker should derive its idempotency key from the source digest plus pipeline version. It should retry transient failures with bounded exponential backoff, honor Retry-After on HTTP 429, and surface non-success response bodies to the operator. Those controls are part of correctness under load, not optional polish.

Buy, build, or combine the stages?

There is no universally best stack. The operationally relevant comparison is which boundaries a platform team wants to own.

Option What it covers Credentials and glue Best fit Boundary to accept
AWS Textract plus a model and Pinecone Managed document extraction, separate structuring, separate vector search At least three service relationships and credential sets; code must translate extraction output into model input and index records Teams already standardized on AWS that want managed extraction and can operate cross-service orchestration More IAM, quota, retry, and billing surfaces
Tesseract plus OpenAI plus Pinecone Self-hosted OCR, hosted model, hosted vector search Two external signups and credential sets, plus Tesseract operations; the team owns chunking and handoff code Teams needing local OCR control and willing to carry its capacity and maintenance OCR scaling and quality operations remain yours
Google Document AI plus a model and a vector store Managed document processing with separately selected structuring and retrieval Multiple service contracts unless the rest of the stack is consolidated Workloads already governed and operated in Google Cloud Portability depends on the adapters and retained intermediate text
DocRaptor, PDFMonkey, or PDFShift HTML-to-PDF generation rather than resume text extraction A separate generation integration that does not replace the parser or model Producing controlled outbound PDFs Wrong direction for this ingestion job
Gotenberg, WeasyPrint, or wkhtmltopdf Self-hostable document conversion or rendering The team operates the renderer and still supplies extraction, structuring, and search Controlled document generation and conversion Not an end-to-end resume parsing stack
Infrai PDF parsing and vector operations are available through one REST API and one key; an OpenAI-compatible surface can keep the model client contract stable One credential surface; the parse-to-index adapter still needs validation, redaction, and idempotency A small platform team that values replacing the provider behind a capability without rewriting callers A broader control plane is still a vendor dependency; validate its contract against your governance needs

This is a buy-versus-build decision, not a feature-count contest. AWS Textract is a sensible choice when AWS identity, procurement, and observability are already sunk costs. Tesseract is attractive when documents must remain inside a controlled environment, but self-hosting transfers upgrades, language data, CPU planning, and quality investigation to the on-call team. Pinecone is purpose-built around vector search; pairing it with either extractor creates an explicit integration boundary, which may be desirable when retrieval is important enough to choose and tune independently. OpenAI can provide the structuring step through a familiar client contract, but it does not remove the need to validate output. DocRaptor, PDFMonkey, PDFShift, Gotenberg, WeasyPrint, and wkhtmltopdf belong in a nearby category, not the same one: they generate or convert documents, so choosing one does not answer how a marketplace extracts and structures an incoming resume.

The consolidated alternative changes the arithmetic of ownership. OCR or parsing, chunk preparation, and vector search can sit behind the same base URL and bearer key, so the pipeline does not need separate authentication and rate-limit adapters at the document-to-index handoff. The current public discovery surface reports 295 capabilities across 20 modules and exposes request and response JSON Schemas; generate adapters from the discovered path and schema rather than description prose. That supports swapping the vendor behind a capability while keeping the application contract stable. It does not absolve the application from its privacy boundary.

Where should personal data be removed?

Redact after extraction but before sharing, persistence in a serving database, or vector upsert. The unredacted extracted text is still sensitive, so retaining it requires restricted access, encryption, a deletion policy, and an explicit reason; “we may need it later” is not governance. If policy forbids retaining it, accept that prompt changes require re-parsing and document that trade-off.

Do not rely on the model to both infer structured fields and prove that no personal data escaped. A deterministic allowlist is easier to audit: construct a new output value containing only fields approved for the downstream purpose. Names, email addresses, and phone numbers in the input type never cross into SharedCandidate. Free-text fields are harder because personal data may appear inside an employment description; either omit them, run a separately tested redaction capability, or route them to review before indexing.

The order is important. Redacting the PDF before parsing can destroy layout or labels the extractor needs, while indexing before redaction makes deletion and containment much harder because sensitive fragments may be duplicated across chunks. Parse, structure, validate, minimize, then share.

When this architecture is the wrong answer

For a handful of trusted, text-only resumes processed interactively, a single local parser followed by manual review may be enough. Adding queues, retained artifacts, and a vector database would create operational surface without a throughput or retrieval requirement.

The design also does not fit a policy that prohibits any unredacted text from reaching an external service. In that case, use an approved local extraction and redaction path first, and treat hosted structuring as eligible only for the minimized text. If exact visual fidelity matters, plain extracted text is insufficient; preserve page references or use a document system built for layout, then validate those references rather than pretending a linear string contains the whole PDF.

My decision rule is blunt: consolidate services when credential sprawl and handoff ownership dominate the on-call burden; keep specialized vendors when extraction quality, retrieval controls, or data residency justify a separate SLO and a separate escalation path. In both cases, the application-owned contract remains the same. Raw text is evidence, validation is the gate, and redaction precedes distribution.

Sources

Top comments (0)