PDF-to-image is one of the highest-volume use cases for conversion pipelines: document preview thumbnails, OCR preprocessing, PDF archival to image sequences. At 1,000 PDFs, the naive serial approach takes 33 minutes. At scale, the architecture changes: you need to think about multi-page handling, per-page vs per-document billing, timeout management for large PDFs, and how to distribute the work across the async jobs queue.
TL;DR - the math for 1K PDFs
Assumptions: average PDF is 5 pages and 8 MB, converts in about 2 seconds, 10 concurrent workers. One PDF is one conversion no matter how many pages it has.
| Batch size | Wall time (10 workers) | Smallest plan that fits | Monthly plan cost | Effective cost per PDF |
|---|---|---|---|---|
| 100 PDFs | ~20 s | Hobby $29 (1,500) | $29 | $0.29 |
| 1,000 PDFs | ~3.5 min | Hobby $29 (1,500) | $29 | $0.029 |
| 10,000 PDFs | ~35 min | Scale $499 (40,000) | $499 | $0.050 |
| 50,000 PDFs | ~2.8 hr | Growth $1,999 (200,000) | $1,999 | $0.040 |
Two things that table makes obvious and are worth planning around.
The free tier cannot validate a batch pipeline. It allows 25 conversions a month, which is enough to prove a single format pair works and nothing else.
There is nothing between 6,000 and 40,000 conversions. The Startup plan stops at 6,000, so a 10,000-PDF batch pays $499 for a plan sized at 40,000. If your batch lands in that gap, either split it across two calendar months on Startup or accept that you are buying headroom you will not use.
For a genuinely one-off batch, remember you are renting a monthly subscription to do a single job. A 1,000-PDF run costs $29 whether you spread it over the month or finish in four minutes - plan to cancel, or to get the rest of the month's quota used.
Naive approach: why sequential fails at 1K
The sequential pattern:
import requests
for pdf_path in pdf_files:
with open(pdf_path, 'rb') as f:
resp = requests.post(
'https://changethisfile.com/v1/convert',
headers={'Authorization': f'Bearer {API_KEY}'},
files={'file': f},
data={'target': 'jpg'},
timeout=200,
)
resp.raise_for_status()
# save output...
At 2s per PDF (fast, single-page): 1K PDFs = 33 minutes. At 5s per PDF (realistic, multi-page): 83 minutes. And this assumes zero failures — a single hung conversion blocks the entire queue. The conversion service allows 180 seconds per synchronous request, so one slow 50-page PDF can stall a worker for three minutes.
For PDFs specifically, there's a second failure mode: memory. Converting a 100-page PDF produces 100 images in a single ZIP response. If you're processing 10 PDFs in parallel, you may be holding 10 large ZIPs in memory simultaneously.
Batching strategies for large PDF sets
Three strategies depending on your volume and PDF characteristics:
Strategy 1: Sync endpoint, worker pool (under 10K PDFs, under 20MB each)
Use /v1/convert with 10-15 concurrent workers. Simple, fast, and correct for most pipelines. Each request holds a connection for the duration of the conversion, within a 180-second budget.
Strategy 2: Async jobs endpoint (large PDFs or high volume)
Use /v1/jobs: POST to submit, then poll GET /v1/jobs/{job_id}. The submit returns HTTP 202 with a job_id; the poll returns a status field, and once that reads completed the response carries a result object with a signed file_url. Signed URLs expire, so download promptly rather than storing them.
import asyncio
import httpx
async def convert_large_pdf(client, pdf_path, target='jpg'):
# Submit the job (202 + job_id)
resp = await client.post(
'https://changethisfile.com/v1/jobs',
headers={'Authorization': f'Bearer {API_KEY}'},
files={'file': (pdf_path.name, pdf_path.read_bytes())},
data={'target': target},
)
resp.raise_for_status()
job_id = resp.json()['job_id']
# Poll until terminal
for _ in range(60): # up to ~5 minutes
await asyncio.sleep(5)
status_resp = await client.get(
f'https://changethisfile.com/v1/jobs/{job_id}',
headers={'Authorization': f'Bearer {API_KEY}'},
)
job = status_resp.json()
if job['status'] == 'completed':
file_url = job['result']['file_url']
download = await client.get(file_url)
return download.content
if job['status'] == 'failed':
raise RuntimeError(f"job {job_id} failed")
raise TimeoutError(f"job {job_id} did not finish in time")
Strategy 3: Split across billing periods
Because quotas hard-stop rather than billing overage, a batch larger than your plan simply fails part way through. If your batch sits just above a plan boundary - the 6,000-to-40,000 gap is the painful one - splitting it across two calendar months on the cheaper plan can be dramatically less expensive than upgrading for a single run.
Full 1K PDF pipeline
#!/usr/bin/env python3
"""Convert a directory of PDFs to JPG images."""
import asyncio
import hashlib
import json
import os
import zipfile
from pathlib import Path
import httpx
API_KEY = os.environ['CTF_API_KEY']
API_URL = 'https://changethisfile.com/v1/convert'
CONCURRENCY = 10
OUTPUT_DIR = Path('output')
FAILURES_FILE = Path('failures.jsonl')
def idempotency_key(pdf_path: Path) -> str:
stat = pdf_path.stat()
payload = f"{pdf_path.resolve()}|jpg|{stat.st_size}|{stat.st_mtime_ns}"
return hashlib.sha256(payload.encode()).hexdigest()[:32]
def already_converted(pdf_path: Path) -> bool:
"""Check if output already exists (any page image)."""
out_dir = OUTPUT_DIR / pdf_path.stem
if not out_dir.exists():
return False
return any(out_dir.glob('page-*.jpg'))
async def convert_pdf(
client: httpx.AsyncClient,
pdf_path: Path,
sem: asyncio.Semaphore,
) -> tuple[str, bool]:
if already_converted(pdf_path):
return pdf_path.name, True # skip
async with sem:
for attempt in range(3):
try:
content = pdf_path.read_bytes()
resp = await client.post(
API_URL,
headers={
'Authorization': f'Bearer {API_KEY}',
'Idempotency-Key': idempotency_key(pdf_path),
},
files={'file': (pdf_path.name, content, 'application/pdf')},
data={'target': 'jpg'},
timeout=200,
)
if resp.status_code == 429:
# Two different 429s. The per-minute rate limit clears on its
# own; the monthly quota does not clear until the billing
# period rolls over, so retrying it just burns the retry
# budget on every remaining file.
body = resp.json() if 'json' in resp.headers.get('Content-Type', '') else {}
if 'quota' in str(body.get('error', '')).lower():
raise SystemExit(
f"Monthly quota exhausted ({body.get('used')}/{body.get('limit')}). "
'No overage is billed - upgrade or wait for the reset.'
)
await asyncio.sleep(int(resp.headers.get('Retry-After', '60')))
continue
resp.raise_for_status()
# Save output (may be ZIP for multi-page)
out_dir = OUTPUT_DIR / pdf_path.stem
out_dir.mkdir(parents=True, exist_ok=True)
ct = resp.headers.get('Content-Type', '')
if 'zip' in ct:
zip_path = out_dir / 'pages.zip'
zip_path.write_bytes(resp.content)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(out_dir)
zip_path.unlink()
else:
(out_dir / 'page-001.jpg').write_bytes(resp.content)
return pdf_path.name, True
except httpx.TimeoutException:
if attempt == 2:
return pdf_path.name, False
await asyncio.sleep(2 ** attempt)
return pdf_path.name, False
async def main():
pdf_files = sorted(Path('.').glob('**/*.pdf'))
print(f'Found {len(pdf_files)} PDFs')
OUTPUT_DIR.mkdir(exist_ok=True)
sem = asyncio.Semaphore(CONCURRENCY)
success = 0
async with httpx.AsyncClient() as client:
tasks = [convert_pdf(client, p, sem) for p in pdf_files]
for i, coro in enumerate(asyncio.as_completed(tasks), 1):
name, ok = await coro
if ok:
success += 1
else:
with FAILURES_FILE.open('a') as f:
json.dump({'file': name}, f)
f.write('\n')
print(f'\r[{i}/{len(pdf_files)}] {success} ok', end='')
print(f'\nDone: {success}/{len(pdf_files)} converted')
if __name__ == '__main__':
asyncio.run(main())
Multi-page PDF output handling
When a PDF has more than one page, the API returns a Content-Type: application/zip response containing one JPG per page, named page-001.jpg, page-002.jpg, etc. Single-page PDFs return the image directly.
Always check the Content-Type before writing the output:
ct = resp.headers.get('Content-Type', '')
if 'zip' in ct:
# Multi-page PDF: extract ZIP
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
zf.extractall(out_dir)
else:
# Single-page PDF: direct image
(out_dir / 'page-001.jpg').write_bytes(resp.content)
For downstream OCR or image processing, you often want page images named consistently regardless of PDF page count. The page-NNN.jpg naming convention the API uses is glob-friendly: sorted(out_dir.glob('page-*.jpg')) gives you pages in order.
Cost tracking and quota monitoring
Effective cost per conversion is the plan price divided by the conversions you actually use: $29 for 1,500 on Hobby is $0.0193 each at full utilisation, $99 for 6,000 on Startup is $0.0165, $499 for 40,000 on Scale is $0.0125, and $1,999 for 200,000 on Growth is $0.0100. Use fewer than the quota and the real per-conversion cost rises accordingly - a 10,000-PDF batch on a 40,000-conversion Scale plan costs $0.05 per PDF, not $0.0125.
There is no quota header on conversion responses. Read your remaining quota from GET /v1/usage before a batch and periodically during long runs:
import httpx
def remaining_quota(api_key: str) -> int:
r = httpx.get(
'https://changethisfile.com/v1/usage',
headers={'Authorization': f'Bearer {api_key}'},
)
r.raise_for_status()
usage = r.json()
# {'plan', 'conversions_this_month', 'monthly_limit', 'max_file_size', 'rate_per_minute'}
if usage['monthly_limit'] == -1:
return float('inf') # enterprise
return usage['monthly_limit'] - usage['conversions_this_month']
left = remaining_quota(API_KEY)
if left < len(pdf_files):
raise SystemExit(
f'Batch needs {len(pdf_files)} conversions, only {left} left this month. '
'Quota hard-stops at 429 - split the batch or upgrade before starting.'
)
Checking up front matters more here than with a metered API. There is no overage on any plan: once the monthly quota is spent every further conversion returns HTTP 429 until the billing period rolls over, so a batch that overruns its quota stops dead rather than costing a little extra. Treat the pre-flight check as part of the pipeline, not as an optional nicety.
Also mind the per-minute rate limit, which is separate from the monthly quota and also returns 429: 60 requests/minute on Hobby, 120 on Startup, 600 on Scale, 3,000 on Growth. Ten workers at two seconds each is about 300 requests a minute, which exceeds Hobby and Startup - throttle your worker pool to match the plan, or you will spend the batch retrying.
PDF-to-image at scale is straightforward once you handle the three things that actually bite: multi-page output arriving as a ZIP, large files needing the async jobs endpoint, and idempotency so a retry does not double-bill. The patterns here scale from 100 to 50,000 PDFs without architectural changes.
What does not scale smoothly is the plan ladder. Work out your batch size first, check it against GET /v1/usage, and pay attention to the gap between 6,000 and 40,000 conversions - that is where a modest increase in batch size turns $99 into $499. A free key gives you 25 conversions a month to validate the format pair before you commit to a plan.