The trap with PNG-to-PDF: most libraries decode the PNG, then re-encode it as part of the PDF, which loses quality and inflates file size. img2pdf is the right answer because it embeds the PNG bytes verbatim — true lossless conversion. Use Pillow only when you need to apply transformations (resize, rotate) along the way.

Method 1: img2pdf (lossless, the right default)

img2pdf embeds the original PNG bytes directly into the PDF — no re-encoding, no quality loss, smallest output file.

pip install img2pdf
import img2pdf
from pathlib import Path

def png_to_pdf(in_path: str, out_path: str) -> None:
    with open(out_path, "wb") as f:
        f.write(img2pdf.convert(in_path))

png_to_pdf("diagram.png", "diagram.pdf")

# Multi-page from many PNGs (sorted):
def pngs_to_pdf(in_dir: str, out_path: str) -> None:
    pngs = sorted(Path(in_dir).glob("*.png"))
    with open(out_path, "wb") as f:
        f.write(img2pdf.convert([str(p) for p in pngs]))

pngs_to_pdf("./pages", "document.pdf")

Three things to know:

  • Lossless — PNG bytes embedded as-is. Output PDF size ≈ sum of PNG sizes + ~5% overhead.
  • Page size auto-derived from image dimensions. A 1920x1080 PNG becomes a PDF page of those exact pixel dimensions (mapped to points).
  • For specific page sizes, use img2pdf.convert(..., layout_fun=img2pdf.get_layout_fun(img2pdf.get_paper_square_layout('A4'))).

Method 2: Pillow (when you need to transform)

If you need to resize, rotate, or composite before saving, Pillow is the natural choice. Tradeoff: it re-encodes the PNG as a JPG inside the PDF (lossy).

pip install Pillow
from PIL import Image
from pathlib import Path

def png_to_pdf(in_path: str, out_path: str, target_dpi: int = 150) -> None:
    img = Image.open(in_path)
    if img.mode == "RGBA":
        # PDF doesn't support transparency well — composite onto white
        bg = Image.new("RGB", img.size, "white")
        bg.paste(img, mask=img.split()[3])
        img = bg
    elif img.mode != "RGB":
        img = img.convert("RGB")
    img.save(out_path, "PDF", resolution=target_dpi)

png_to_pdf("diagram.png", "diagram.pdf")

# Multi-page:
def pngs_to_pdf(in_dir: str, out_path: str, dpi: int = 150) -> None:
    pngs = sorted(Path(in_dir).glob("*.png"))
    images = []
    for p in pngs:
        img = Image.open(p)
        if img.mode != "RGB":
            img = img.convert("RGB")
        images.append(img)
    images[0].save(out_path, "PDF", resolution=dpi, save_all=True, append_images=images[1:])

pngs_to_pdf("./pages", "document.pdf")

Pillow's PDF output is convenient but lossy. For pixel-perfect output (diagrams, screenshots, charts), use img2pdf.

Method 3: ChangeThisFile API (no library)

If you don't want either dep, the API uses img2pdf-equivalent lossless embedding server-side. Free tier covers 1,000 conversions/month.

import requests

API_KEY = "ctf_sk_your_key_here"

def png_to_pdf(in_path: str, out_path: str) -> None:
    with open(in_path, "rb") as f:
        response = requests.post(
            "https://changethisfile.com/v1/convert",
            headers={"Authorization": f"Bearer {API_KEY}"},
            files={"file": f},
            data={"source": "png", "target": "pdf"},
            timeout=60,
        )
    response.raise_for_status()
    with open(out_path, "wb") as out:
        out.write(response.content)

png_to_pdf("diagram.png", "diagram.pdf")

The API is single-PNG by default. For multi-PNG-to-one-PDF, use the /v1/jobs endpoint with multiple file fields, or upload a zip of PNGs with target=pdf and pass combine=true in form data.

When to use each

ApproachBest forTradeoff
img2pdfLossless conversion, smallest output, the right defaultNo image transformations
PillowWhen you need resize/rotate/composite firstLossy re-encoding, larger files
ChangeThisFile APINo library install, edge runtimesNetwork call, file size limit (25MB free)

Production tips

  • Always prefer img2pdf for lossless. Pillow's PDF mode is convenient but quietly recompresses. For diagrams, screenshots, or any sharp-edge content, img2pdf is the right answer.
  • Decide on page size explicitly. img2pdf defaults to image-pixel-as-page-point (a 1920x1080 PNG becomes a 1920x1080-pt page — much larger than A4). For US Letter or A4 output, use img2pdf.get_layout_fun.
  • For PNG with transparency, decide handling. PDF supports transparency but many viewers render it inconsistently. Composite onto white background before save if your audience is using older PDF readers.
  • Multi-page from many PNGs is one call. Don't loop over single-page PDFs and merge — pass the list to img2pdf.convert() in one call.
  • For very large batches, stream to disk. img2pdf.convert with a list of paths processes lazily; memory stays bounded.

For nearly every PNG-to-PDF case in Python, img2pdf is the right answer. Pillow only when you need transformations. The API for environments where you can't add either dep. Free tier covers 1,000 conversions/month.