How to Bulk-Parse a Legacy CV Database
A decade of hiring lives in that folder of attachments. Turning it into a searchable candidate database is one weekend of queue work — if you architect around the async API instead of against it.
Bulk resume parsing converts a large document backlog — inbox attachments, legacy ATS exports, shared-drive folders — into structured candidate records. The working pattern: enumerate files, submit each as an async parsing job with a webhook, consume results as events, dedupe on contact fields.
Why async changes the architecture
Every submission to parse_resume returns in milliseconds with a status URL; the parsing happens server-side, in parallel, regardless of how many jobs you have in flight. Your bottleneck is submission concurrency and your own result handling, not the parsing. A 10,000-file backfill is therefore a queue problem, and queues are a solved problem.
The pipeline, step by step
- Enumerate and fingerprint. Walk the folder, hash every file, skip duplicates before spending a single API word; legacy archives are reliably duplicate-heavy.
- Submit with webhooks. One queued job per file: upload, register your webhook URL, store the job_id against the file hash. Do not poll 10,000 status URLs — let the results come to you.
- Consume results as events. Your webhook route receives each completed job; validate, store the JSON, mark the file done. Failures (corrupt files, password-protected PDFs) arrive as failed statuses — log and continue, never halt the run.
- Dedupe on parsed data. The file-level hash catches identical files; candidate_email + name catches the same person across five resume versions. Keep the newest, link the rest.
- Index for search. The deterministic schema maps directly to database columns — per-role skills and dates make “Python, 5+ years, management experience” a WHERE clause instead of a keyword grep.
A Laravel sketch
class ParseLegacyResume implements ShouldQueue
{
public function __construct(private string $path) {}
public function handle(ResumeParserService $parser): void
{
$statusUrl = $parser->parseResume($this->path, 'English');
PendingParse::create([
'file_hash' => hash_file('sha256', $this->path),
'status_url' => $statusUrl,
]);
// Result arrives at your webhook route; no polling loop anywhere.
}
}
Estimating the cost before you run it
Per-word metering makes the estimate honest: a typical two-page resume runs 500–800 processed words. Sample 50 random files from your archive, check their word counts, multiply by the archive size and compare against plan allowances — the pricing page lists the tiers, and the 100,000-word trial covers a meaningful pilot batch of a few hundred documents free.
After the backfill: keep it warm
The same webhook pipeline, pointed at your intake email or upload form, keeps the database current forever — and once every candidate is structured data, the screening pattern can match your entire historical bench against every new role in one batch run. That rediscovery capability is usually the feature that justifies the whole project.
Questions, answered
How fast can I parse 10,000 resumes?
As fast as you can submit them. The API is async — every POST returns immediately with a status URL — so wall-clock time is dominated by your submission concurrency, not the parsing.
Should I use polling or webhooks for bulk jobs?
Webhooks. Polling 10,000 status URLs wastes requests and time; a webhook per job turns the backfill into an event stream your queue workers consume.
How do I estimate bulk parsing cost?
A typical two-page resume runs 500-800 processed words. Multiply your document count by your average length and compare against plan word allowances — per-word metering means short documents cost proportionally less.