The ticket said “parser is working fine.” It was. Extraction confidence was high, the dead letter queue was empty, and recruiting liked the shortlists. Then someone in legal asked why candidate 4,412 ranked below candidate 118.
Nobody could answer. Not because anything was broken, but because the system had quietly stopped being a parser somewhere between the S3 upload and the sorted list on a recruiter’s screen. It had become a selection procedure, and nothing in it was designed to explain itself.
That is the failure mode worth leading with, because it never pages you. Your alarms stay green while the thing you built lands in a category of regulated system you never intended to build.
This post covers CV parsing on AWS as it actually goes: what Amazon Textract gives you and what it doesn’t, where Bedrock belongs and where it becomes a liability, the bias problem you have to document rather than solve, and the failures that only surface at volume.
When CV parsing on AWS becomes a hiring decision
Most of these pipelines start honestly. Someone wants structured JSON out of a PDF pile so the ATS populates fields instead of a coordinator retyping them. Extraction is a data problem with clean success criteria.
Then a product manager asks for a match score. It seems small. You already have the skills list and the job description, so you ask a model how well they line up and emit a number. The API contract barely changes.
What changed is everything. The moment a recruiter sorts by that number and works down from the top, the pipeline substantially assists a hiring decision. Under New York City’s Local Law 144 that is an automated employment decision tool, and the obligations attach to the employer using it, not the vendor who wrote the code. Under the EU AI Act, Annex III point 4 lists systems intended to analyse and filter job applications and evaluate candidates as high-risk by classification, not by how confident you are in your prompt.
The EU date is worth stating plainly because it moved. Annex III obligations were originally due to apply from 2 August 2026; the Digital Omnibus package pushed stand-alone Annex III systems out to 2 December 2027. That is a reprieve on paperwork, not on design. Systems procured now will still be running then, and the separate prohibition on workplace emotion-recognition AI, plus existing GDPR duties around automated decisions, were never on that timetable.
So the first architectural decision isn’t about services. It’s about where extraction stops and judgement begins, and whether that boundary is visible in the code or buried in a prompt.
Getting text out: what Textract actually does
Textract is good at CVs in a narrow way. It reads printed and handwritten text, detects tables and form-style key-value pairs, and with the Layout feature type it identifies structural elements such as titles, paragraphs, lists, headers and footers. For a two-column CV with a skills sidebar, layout awareness is the difference between usable text and word salad.
Sync versus async is not a performance choice
AnalyzeDocument is synchronous and takes a single image or a document passed as bytes or an S3 object. StartDocumentAnalysis is the asynchronous entry point for multipage documents; it publishes completion status to an SNS topic you drain from SQS before calling GetDocumentAnalysis with the returned job ID.
People pick sync because it’s easier to reason about, then discover half of real CVs are three pages. Go async from the start. The SNS-to-SQS hop isn’t overhead, it’s the only thing that gives you a retry surface when a job fails partway through a batch.
Queries beat walking the block graph by hand
The Queries feature lets you ask natural-language questions of a document without knowing where the data sits on the page. Pass QUERIES in FeatureTypes, supply a QueriesConfig, then read the QUERY_RESULT blocks out of the response.
aws textract start-document-analysis
--document '{"S3Object":{"Bucket":"bucket","Name":"document"}}'
--feature-types '["QUERIES"]'
--queries-config '{"Queries":[{"Text":"Question"}]}'
Each query can carry an alias so answers map back to your schema without positional guessing. In Python, walk the returned blocks and pick the ones whose BlockType is QUERY_RESULT, each carrying answer text and a confidence score.
response = client.analyze_document(
Document={'S3Object': {'Bucket': bucket, 'Name': document}},
FeatureTypes=["TABLES", "FORMS", "QUERIES"],
QueriesConfig={'Queries': [{'Text': question}]}
)
for block in response['Blocks']:
if block["BlockType"] == "QUERY_RESULT":
print(block["Text"])
Use Queries for facts that exist verbatim on the page: employer name, job title, dates, certification number. Not for anything requiring inference. “What is this person’s seniority level?” is a judgement, and pushing it through an extraction API hides that judgement inside something that looks like OCR.
Where Bedrock belongs, and where it stops
Bedrock earns its place in one job: turning messy extracted text into a consistent schema. CVs are the worst kind of semi-structured document because the structure is a fashion trend. One person lists dates as “Mar 2019 to present,” another renders them as a bar chart. Normalising that is exactly the fuzzy transformation a language model handles well, and one you can verify.
- Constrain output to a schema and reject anything that fails validation. A job title you didn’t ask for, or an employment date absent from the source text, is a failed record rather than a creative flourish.
- Turn on model invocation logging before you process a single real CV. It’s disabled by default, and it’s the only native record of what you sent and what came back.
That second point isn’t a nice-to-have. When someone asks a year from now why a candidate scored the way they did, the invocation log is your answer or you don’t have one.
aws bedrock put-model-invocation-logging-configuration
--logging-config '{
"s3Config": {"bucketName": "my-bedrock-logs", "keyPrefix": "cv-pipeline/"},
"textDataDeliveryEnabled": true
}'
Logs go to CloudWatch Logs, S3, or both, and only destinations in the same account and Region are supported. Apply lifecycle rules and encryption up front, because these logs contain full CVs and they outlive the hiring round that produced them.
Bedrock Guardrails fits here through sensitive information filters, which detect PII in prompts and responses and either block the payload or mask individual entities with identifier tags. There’s a detect mode that reports what a guardrail would have caught without acting, which is the right way to tune against real documents first. Don’t oversell it to yourself: masking is not anonymisation, and a filter firing on NAME and EMAIL does very little about bias, for reasons that are the whole point of the next section.
The bias problem you must document, not solve
Every engineer’s instinct is to strip the protected attributes and declare it handled. Remove the name, the photo, the address, and the model can’t discriminate on what it can’t see. It doesn’t work, and understanding why separates a pipeline you can defend from one you can’t.
Proxies survive redaction
A CV is dense with correlated signal. University name tracks geography and socioeconomics. Graduation year is an age proxy that survives every name redaction you can write. Employment gaps correlate with caregiving. Postcode correlates with almost everything. Add professional association membership, the language a qualification is written in, whether military service appears, the phrasing conventions of a non-native English speaker.
None of those are protected attributes. All of them carry information about protected attributes, and a model optimising for similarity to your existing successful hires will use them. Redaction removes the label, not the signal, and it makes disparity harder to detect because you deleted the field you would have measured against.
You can’t prove a screening pipeline is unbiased. You can measure its outcomes, record what you measured, and act when the numbers move.
What the four-fifths rule actually asks for
The Uniform Guidelines on Employee Selection Procedures set out the rule of thumb federal enforcement agencies apply: a selection rate for any race, sex or ethnic group falling below four-fifths of the rate for the highest group is generally treated as evidence of adverse impact. It sits at 29 CFR 1607.4(D).
- It’s a rule of thumb, not a safe harbour. The same guidelines note smaller differences can still constitute adverse impact where they’re significant in statistical and practical terms, and courts have declined to treat clearing 0.8 as proof nothing is wrong.
- It applies to the selection procedure. That means your parser, your scorer and the recruiter’s sort order taken together. Auditing the model in isolation measures the wrong thing.
The computation is trivial, which is why there’s no excuse for skipping it.
SELECT
demographic_group,
COUNT(*) AS applicants,
SUM(CASE WHEN advanced THEN 1 ELSE 0 END) AS selected,
1.0 * SUM(CASE WHEN advanced THEN 1 ELSE 0 END) / COUNT(*) AS selection_rate
FROM screening_outcomes
WHERE requisition_id = :req
GROUP BY demographic_group;
Divide each selection rate by the largest and you have the impact ratio. Anything under 0.8 is a signal to stop and look, not a number to explain away.
Where the demographic data comes from
This is the genuinely hard part and where most teams stall. You need demographic data to measure disparity, and you must not feed it into the screening decision. The pattern that works is a strict split: self-reported responses collected separately at application time, stored in a different bucket under a different KMS key, joined to outcomes only in an analysis account the scoring pipeline has no path to.
NYC’s regime allows historical or test data, and requires the audit be performed by someone not involved in using or developing the tool. That independence requirement is easy to miss when you’re the consultant who built the thing. You can build the measurement harness. You can’t be the independent auditor of your own pipeline.
Document the pipeline, not the model
The artifact that saves you is boring and written down. At minimum: which model and version produced scores in each date range, the scoring logic in force, every field that reached the scorer, every field withheld and why, where human review sits and what a reviewer can override, and the impact ratios from each measurement run.
Keep it in version control beside the infrastructure code. A change to the prompt is a change to the selection procedure and should be reviewable as one.
Problems that only appear at volume
- Throttling arrives as data loss. Without retries using exponential backoff and jitter, a throttled call becomes a missing candidate rather than an error anyone sees. Assert on record counts at every stage boundary.
- Duplicates are the norm. The same person applies to three roles with two slightly different files. Deduplicate on content hash before you spend on extraction, and keep an idempotency key so a retried job doesn’t create a second candidate record.
- Cost is dominated by re-runs. Extraction is the expensive step and it’s deterministic on the same input. Store the raw Textract response in S3 keyed by document hash. Reprocessing after a schema change should cost storage, not inference.
- Retention outlives the requisition. Extracted text, invocation logs and intermediate JSON all contain personal data and all quietly accumulate. Set lifecycle rules at build time, not after the first data subject request.
- Scanned CVs are a different pipeline. Photographed documents behave nothing like exported PDFs. Route them separately with lower confidence thresholds and higher review rates.
On cost visibility, native tooling shows the shape of spend but is slow to attribute it. If this runs alongside other AI workloads, a dedicated platform such as Vantage or CloudZero makes per-pipeline attribution less painful than slicing Cost Explorer by tag every month. And if the upload front door is public, putting Cloudflare in front of it deals with bot traffic before it becomes Textract spend.
Troubleshooting
- High confidence, wrong fields. Almost always multi-column layouts read in the wrong order. Add the Layout feature type and check reading order in the block graph against what a human sees.
- Async jobs complete but results never arrive. Check the SNS topic policy allows publishing to your queue and that the role passed to Textract can publish. This fails silently and looks like a stuck pipeline.
- The model invents employment history. Your prompt is asking it to summarise rather than extract. Constrain the schema, require every value trace to source text, and fail validation instead of accepting a plausible answer.
- Scores shift after a model update. Pin the model identifier rather than tracking a moving alias, and re-run your measurement set against a held-out corpus before promoting any change.
- Guardrails block legitimate CVs. Sensitive information filters are probabilistic and context-dependent. Run detect mode against a real corpus and read the traces before switching to block.
Common mistakes
- Treating a match score as a data field rather than a decision, and skipping the governance that follows.
- Redacting names and considering the bias question closed.
- Enabling invocation logging after the pilot, leaving the first thousand decisions with no record.
- Building human review that only sees the shortlist, which reviews the output of the bias rather than the bias.
- Assuming a US-headquartered employer is out of scope. Both regimes reach based on where the candidate is, not where the company is.
Best practices
- Draw a hard line between extraction and scoring: separate services, separate IAM roles, separate logs.
- Enable model invocation logging before the first real document, with encryption and lifecycle rules already applied.
- Cache raw extraction output keyed by document hash so reprocessing never re-bills inference.
- Collect demographic data separately, key it differently, and join it to outcomes only in an isolated analysis account.
- Run impact ratios on a schedule rather than on request, and alert on movement instead of waiting for an audit.
- Version prompts and scoring logic in the same repository as the infrastructure, reviewed as changes to a selection procedure.
Frequently asked questions
Is Textract or Bedrock the right tool for CV parsing?
Both, for different halves. Textract turns pixels into text with layout awareness and confidence scores. Bedrock turns that text into a consistent schema. Using a language model for the OCR step gives up per-field confidence, which is exactly the signal you need to decide what goes to human review.
Does removing names and photos make a resume screening pipeline compliant?
No. Redaction handles direct identifiers while leaving proxies such as graduation year, institution, postcode and employment gaps intact. It also removes the fields you’d need to measure disparity, so it can make a pipeline harder to audit while feeling safer.
Do these rules apply if my company isn’t in New York or the EU?
Possibly. Local Law 144 turns on whether the candidate resides in New York City, including for remote roles, rather than where the employer sits. The EU AI Act has similar extraterritorial reach. Check with counsel, not with an architecture diagram.
Can I run the bias audit myself?
You can build the measurement pipeline and run it continuously, and you should. The formal NYC bias audit has to be performed by an independent party not involved in using or developing the tool, which rules out the team that built it.
How do I keep extraction costs predictable at scale?
Deduplicate before extraction, cache raw responses by content hash, and choose feature types deliberately. Requesting tables, forms and queries on every document when you only need queries multiplies cost for nothing. Both services bill per unit of work, so the lever is doing less work rather than finding a cheaper tier.
Should the pipeline auto-reject candidates?
Treat that as a question for counsel, not engineering. GDPR restricts solely automated decisions with significant effects on individuals, and the practical answer in most designs is a meaningful human review before any rejection. Build the human step in early; retrofitting it is painful.
The one thing worth remembering
CV parsing on AWS is two systems wearing one name. The extraction half is an engineering problem with clean success criteria, and Textract plus Bedrock will carry you a long way given retries, caching and schema validation. The scoring half is a selection procedure, governed by rules that don’t care what you called the service.
You won’t eliminate bias from a system trained on hiring history. What you can do is know where the boundary sits, log what crossed it, measure outcomes on a schedule, and be able to answer the question about candidate 4,412 when it arrives. Build the documentation while you build the pipeline. Reconstructing it later from CloudWatch and memory is a much worse job.
Work with me on document pipelines and AI governance
Most of the work here isn’t the extraction. It’s the boundary drawing, the evidence trail and the parts that only break at volume. Things I help with:
- Designing Textract and Bedrock pipelines with an auditable split between extraction and any scoring or ranking step.
- Building the measurement harness that computes selection rates and impact ratios on a schedule, with demographic data isolated from the scoring path.
- Setting up model invocation logging, retention, encryption and access controls so the evidence trail exists before you need it.
- Fixing throughput and cost problems: throttling, deduplication, extraction caching and reprocessing that doesn’t re-bill inference.
- Adding human-in-the-loop review that routes on confidence and score bands, including sampled rejections rather than shortlists only.
- Writing the technical documentation an independent auditor or your legal team will actually ask for.
Send me a sample Textract response, a scoring prompt or an architecture sketch and I’ll tell you where the boundary is currently blurred.