PDFs

Compress PDFs from the Terminal on macOS

By the Smol team13 min read

To compress a PDF from the Terminal on macOS, install Ghostscript with Homebrew and run one gs command with -dPDFSETTINGS=/ebook. That took our 34.8 MB layout export to 251,841 bytes, with the text untouched.

It is also the slowest of the three tools here, it does nothing at all to some files, and it is the wrong choice for about half the jobs people point it at. This is the honest version: what each tool actually does, what the presets really contain, and every command run on the same fixtures so the numbers line up.

We make a Mac compression app, so there is one commercial mention near the end. The rest is free software and should be useful whether or not you ever open ours.

Which command-line tool for which job?

These three do genuinely different things, and picking wrong is the most common way to waste an afternoon.

ToolWhat it doesReach for it when
Ghostscript (gs)Rebuilds the PDF, resampling images to a DPI target and re-encoding them.You need a specific resolution, or the file is text-heavy. It saves most on a text-only PDF of anything here.
qpdfRepacks the file structure losslessly. Never changes a pixel.You must not alter the content. Also the fastest way to turn uncompressed image streams into JPEG.
Quartz filter (built in)Runs Preview’s Reduce File Size from a script. Fixed at 144 DPI, quality 0.70, 2,400 px ceiling.Nothing is installed and the job is one screen-destined file. Not for scans with OCR.
OCRmyPDFAdds a searchable text layer, optionally re-encoding images.The file is a scan. Run this before any of the above.
brew install ghostscript qpdf poppler
brew install ocrmypdf   # optional, for scans

poppler is not a compressor. It gives you pdfimages, pdffonts, pdfinfo and pdftotext, which are how you find out what you are dealing with and whether the pass did what you think it did.

How do you see what is inside a PDF before you compress it?

Three commands, ten seconds, and they decide everything that follows.

pdfinfo report.pdf                 # pages, page size, producer, encryption
pdfimages -list report.pdf         # every image: pixels, encoding, effective PPI
pdffonts report.pdf                # every font: embedded? subsetted? unicode map?

The pdfimages -list output is the one that matters. It prints an x-ppi column, which is the effective resolution of each image at the size it is drawn on the page. On our twelve-page layout export it read 1719 and 2824. On our scan it read 300. On our text-only document it printed a header and no rows.

Those three shapes need three different treatments, and a tool that helps one can be a no-op or a regression on the others.

If you want everything in one dump, mutool info (from brew install mupdf-tools) prints the document info dictionary, media boxes and fonts together.

What do Ghostscript’s /screen, /ebook, /printer and /prepress actually do?

The canonical invocation:

gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.7 \
   -dPDFSETTINGS=/ebook -dNOPAUSE -dQUIET -dBATCH \
   -sOutputFile=out.pdf in.pdf

The presets are not a mystery. They are PostScript dictionaries in Ghostscript’s own initialisation file, and you can read them:

grep -A 22 "^/prepress mark" \
  "$(brew --prefix)/share/ghostscript/Resource/Init/gs_pdfwr.ps"
PresetColor / gray targetMonoDownsampleJPEG QFactorColor strategy
/screen72 dpi300 dpiAverage0.76sRGB
/ebook150 dpi300 dpiAverage0.76sRGB
/printer300 dpi1200 dpiAverage0.40Device independent
/prepress300 dpi1200 dpiBicubic0.15Leave unchanged
Read from gs_pdfwr.ps, Ghostscript 10.07.0. Lower QFactor means higher JPEG quality. All four inherit a downsample threshold of 1.5.

That threshold is the single most important number on this page. Ghostscript only downsamples an image when its resolution exceeds the target multiplied by 1.5, a default documented in VectorDevices. So /printer leaves anything at or below 450 PPI completely alone. Watch what that does in practice:

File/screen/ebook/printer/prepress
Layout export, 34.8 MB, images at 1,719–2,824 PPI112 KB (−99.7%)252 KB (−99.3%)1.67 MB (−95.2%)2.56 MB (−92.6%)
Brochure, 16.8 MB, images at 342 PPI351 KB (−97.9%)1.23 MB (−92.7%)16.8 MB (−0.0%)16.8 MB (−0.0%)
Scan, 5.02 MB, images at 300 PPI244 KB (−95.1%)646 KB (−87.1%)5.02 MB (+0.0%)5.02 MB (−0.0%)
Text-only, 87,788 B, no images67,418 B (−23.2%)67,396 B (−23.2%)70,149 B (−20.1%)67,387 B (−23.2%)
Raw bytes from stat. Both 342 PPI and 300 PPI sit under the 450 PPI trigger, so /printer and /prepress rebuild those files and change nothing.

Two things to take from that. First, Ghostscript does best on a text-only PDF, taking about a quarter off where qpdf manages 14.4% and Preview’s filter adds 24.2%. Second, /printer on a 300 PPI scan is a silent no-op that reports success and returns a file 1,745 bytes larger. If a preset appears to do nothing, check the source resolution before you blame the tool.

Speed is the real cost. On the 34.8 MB layout, /screen took 111.74 seconds and /ebook 90.52 seconds, against 3.51 seconds for /printer on the same file. Heavy downsampling of ICC-tagged images is where gs spends its afternoon.

How do you set an exact DPI target instead of a preset?

Skip -dPDFSETTINGS and set the distiller parameters yourself. Setting the threshold to 1.0 is what makes the command act on images that are only slightly above the target:

gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.6 \
   -dDownsampleColorImages=true \
   -dColorImageDownsampleType=/Bicubic \
   -dColorImageResolution=300 \
   -dColorImageDownsampleThreshold=1.0 \
   -dDownsampleGrayImages=true \
   -dGrayImageDownsampleType=/Bicubic \
   -dGrayImageResolution=300 \
   -dGrayImageDownsampleThreshold=1.0 \
   -dAutoFilterColorImages=false -dColorImageFilter=/DCTEncode \
   -dEmbedAllFonts=true -dSubsetFonts=true \
   -dNOPAUSE -dQUIET -dBATCH \
   -sOutputFile=out.pdf in.pdf

Against the same three files, at an exact 300 dpi with the threshold disarmed:

FileOutputChangeResulting PPITime
Layout export, 34.8 MB552,962 B−98.4%3003.75 s
Brochure, 16.8 MB4,059,670 B−75.9%3002.54 s
Scan, 5.02 MB1,827,152 B−63.6%3000.50 s

That layout row is worth pausing on. 34.8 MB to 553 KB, and every image still sits at exactly 300 PPI, which is what a commercial printer asks for. Nothing was sacrificed except pixels the page could never show. Compressing a PDF for print goes into what 300 actually buys you.

Swap the resolution for a size ladder. On the 5.02 MB scan:

ColorImageResolutionOutputChange
100308,143 B−93.9%
150568,286 B−88.7%
200881,919 B−82.4%
2501,273,289 B−74.6%
3001,827,152 B−63.6%

And the lever nobody uses: grayscale. On a black-ink document it removes two colour channels and keeps every pixel.

gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.6 \
   -sColorConversionStrategy=Gray -dProcessColorModel=/DeviceGray \
   -dNOPAUSE -dQUIET -dBATCH -sOutputFile=gray.pdf in.pdf

That took the scan to 1,720,854 bytes, down 65.7%, still at a full 300 PPI. One caveat we hit and will not write around: when Ghostscript rebuilds a PDF produced by a browser print engine, poppler and MuPDF print an lcms warning about a zero-length ICC profile in the output. The pages render correctly in Preview, in PDFKit and in MuPDF regardless, but check your output in whatever will actually consume it.

When is qpdf the right tool?

When the content must not change. qpdf rewrites the file structure, not the page content: object streams, cross-reference tables, Flate compression levels. It cannot alter a single pixel, which is exactly why it is useful for anything signed, legal or archival.

qpdf --object-streams=generate --recompress-flate --compression-level=9 \
     in.pdf out.pdf
FileOutputChangeTime
Text-only, 87,788 B75,103 B−14.4%0.05 s
Scan, 5,017,560 B5,015,593 B−0.0%0.03 s
Brochure, 16,821,680 B16,812,628 B−0.1%0.05 s
Layout export, 34,826,340 B34,810,799 B−0.0%0.11 s
Genuinely lossless. On image-heavy files there is nothing structural left to win.

That is the honest picture: about a seventh off a text PDF, and effectively nothing off anything made of photographs. Structure is not where those bytes live.

There is one case where qpdf is the best tool on this page by a wide margin. If a PDF stores its images uncompressed or Flate-encoded rather than as JPEG, which happens with exports from drawing tools, screenshots pasted into documents and some scanner drivers, --optimize-images converts them without touching the pixel dimensions:

qpdf --optimize-images in.pdf out.pdf
Two-page PDF with Flate-encoded 2,550 × 3,300 imagesOutputChangeImage size afterTime
Source19,873,274 B—2,550 × 3,300 at 300 PPI—
qpdf lossless repack19,870,036 B−0.0%2,550 × 3,300 at 300 PPI6.35 s
qpdf --optimize-images1,989,730 B−90.0%2,550 × 3,300 at 300 PPI0.33 s
Ninety percent off in a third of a second, with the resolution untouched. Worth trying before anything that resamples.

Two more qpdf commands worth having. Splitting, for upload forms that cap file size but allow several files:

qpdf --split-pages=4 big.pdf part.pdf          # part-01-04.pdf, part-05-08.pdf, …
qpdf --empty --pages big.pdf 1-6 -- first-half.pdf

Be careful with that one. Splitting our twelve-page layout, whose pages reuse the same eight photographs, produced 91.5 MB of parts from a 34.8 MB source, because each part carries its own copy of every shared object. On the scan, where every page has a unique image, splitting was effectively free. More on that in what to do when a PDF is too big to upload.

Can you run Preview’s Reduce File Size from a script?

Yes, and nothing needs installing. Preview’s filter is a 949-byte property list you can read:

plutil -p "/System/Library/Filters/Reduce File Size.qfilter"

Its entire mechanism: ImageCompression of ImageJPEGCompress, Compression Quality of 0.70, ImageResolution of 144, and an absolute ImageSizeMax of 2400 pixels. There is no adaptive logic and no setting exposed in the UI.

To apply it from the command line, hand it to PDFKit. Save this as qpdfilter.swift; it needs only the Xcode command line tools:

#!/usr/bin/env swift
import Foundation
import Quartz

let a = CommandLine.arguments
guard a.count == 4 else {
    FileHandle.standardError.write("usage: qpdfilter <filter.qfilter> <in.pdf> <out.pdf>\n".data(using: .utf8)!)
    exit(2)
}
guard let filter = QuartzFilter(url: URL(fileURLWithPath: a[1])),
      let doc = PDFDocument(url: URL(fileURLWithPath: a[2])) else { exit(1) }
let ok = doc.write(to: URL(fileURLWithPath: a[3]),
                   withOptions: [PDFDocumentWriteOption(rawValue: "QuartzFilter"): filter])
exit(ok ? 0 : 1)
swift qpdfilter.swift \
  "/System/Library/Filters/Reduce File Size.qfilter" in.pdf out.pdf
FileOutputChangeResulting PPITime
Layout export, 34.8 MB688,518 B−98.0%1441.71 s
Brochure, 16.8 MB4,052,530 B−75.9%1371.50 s
Scan, 5.02 MB1,793,483 B−64.3%1440.56 s
24 × 36 in poster, 3.17 MB954,969 B−69.9%671.74 s
Text-only, 87,788 B109,049 B+24.2%n/a1.63 s
Fast, free, no dependencies, and two rows you need to read before using it.

The poster row is the ImageSizeMax 2400 ceiling doing its work: a 24 × 36 inch page whose image was already at a sober 134 PPI comes out at 67 PPI, because 2,400 pixels across 36 inches is 67 PPI and the filter does not care what the page size is.

The text-only row is the filter rebuilding a document it has nothing to compress in, and adding a quarter to its size in the process. And on a scan carrying an OCR text layer, this route destroys the layer outright, which we measured in compressing a scanned PDF without killing OCR.

Copy the plist into ~/Library/Filters/ and edit those three numbers and you have your own filter, which also appears in Preview’s export menu. That trick and its limits are covered in the Preview autopsy.

How do you run this over a whole folder?

A loop, and a subdirectory so you never overwrite an original:

mkdir -p small
for f in *.pdf; do
  gs -sDEVICE=pdfwrite -dPDFSETTINGS=/ebook -dNOPAUSE -dQUIET -dBATCH \
     -sOutputFile="small/${f%.pdf}-small.pdf" "$f"
done

Then check the arithmetic before you trust it:

for f in *.pdf; do
  printf "%-40s %9s -> %9s\n" "$f" \
    "$(stat -f%z "$f")" "$(stat -f%z "small/${f%.pdf}-small.pdf")"
done

That is where the speed question stops being academic. At roughly 90 to 112 seconds per file on image-heavy input, a folder of fifty design exports is an hour and a half of one core. A native batch queue on the same machine finished the 34.8 MB layout at its medium preset in 889 milliseconds, and the 16.8 MB brochure in 558. That gap, about 126 to 1 against gs -dPDFSETTINGS=/screen on the same file, is the practical case for not doing everything in a shell.

If you live in an editor rather than a terminal, there is a third route: our app ships an MCP server, so an AI coding agent drives it directly. As of Smol 1.0.35 that means Claude Code, Codex and Google Antigravity, each one click in the app’s AI access panel. In practice you type “compress every PDF under docs/ to the small preset and report the total savings” and the agent resolves the paths, runs the job and reports the byte counts back without you writing the loop. Details on the MCP page.

When the Terminal is the wrong answer

When the file is a scan with no text layer. No command here adds one. Run ocrmypdf scan.pdf searchable.pdf first. On our test scan the default invocation added a full 1,405-word searchable layer and made the file 45.1% smaller in 9.77 seconds, by re-encoding the scanner’s wasteful JPEGs at full resolution.

When you need to see the result. Compression is a judgement call about a picture. A shell gives you a byte count, not a page. Render a page to check before you commit to a setting across a folder: mutool draw -o page.png -r 150 out.pdf 1.

When it is one file and you are not going to script it again. Preview does this for free with no dependencies. There is no prize for using the terminal.

When speed matters more than control. Ghostscript spent 111.74 seconds at /screen on a file our app finished in 889 milliseconds, and its output landed at 72 PPI against 344. It is smaller and considerably coarser, which is a trade you should make on purpose rather than by default. Smol is $29 once, it does folders and watched directories, and it will not make a text-only PDF bigger. For the cases where a free tool beats it outright, and there are several, the Mac PDF compressor comparison names them. The five-method version is in how to compress a PDF on Mac.

Versions and method

Every command on this page was executed on 26 September 2026 on a MacBook Pro (Mac14,9) with an Apple M2 Pro, 10 cores and 16 GB of RAM, running macOS 27.0 (build 26A428). Sizes are raw bytes from stat -f%z. Timings are wall clock, single run, warm cache. Resolutions come from pdfimages -list.

Versions. Ghostscript 10.07.0, qpdf 12.3.2, poppler 26.04.0, OCRmyPDF 17.4.2, Tesseract 5.5.2, MuPDF 1.27.1, Swift 6.4, Smol 1.0.35.

Test files. Six fixtures built for this article, so the results are reproducible and no private document was involved. A 1,405-word services agreement we wrote, rendered to PDF by headless Chrome (87,788 bytes, three pages, no images). The same pages rasterized to 300 PPI and given scanner artifacts with ImageMagick 7.1.2 (5,017,560 bytes), plus an OCR’d copy of it and a two-page Flate-encoded variant (19,873,274 bytes). An eight-page A4 brochure with full-bleed synthetic photographs at 342 PPI (16,821,680 bytes). A twelve-page layout with twenty-four placements of eight synthetic 6,000 × 4,000 photographs at 1,719 and 2,824 PPI (34,826,340 bytes). And a 24 × 36 inch poster page at 134 PPI (3,168,921 bytes). The photographs are generated plasma-and-noise composites, not anyone’s camera roll.

Frequently asked questions

What is the best Ghostscript setting to compress a PDF?

/ebook is the safe default: it targets 150 dpi and took our 34.8 MB layout export to 251,841 bytes and a 5 MB scan to 645,578 bytes. Use /screen at 72 dpi only for throwaway previews. Avoid /printer and /prepress unless your images are above 450 PPI, because the default downsample threshold of 1.5 means they will otherwise do nothing at all.

Why did Ghostscript not make my PDF any smaller?

Almost certainly the downsample threshold. Ghostscript only resamples an image when its resolution exceeds the target multiplied by 1.5, so /printer at 300 dpi ignores anything at or below 450 PPI. Our 300 PPI scan came back 1,745 bytes larger from /printer and the command reported success. Check the x-ppi column of "pdfimages -list" first.

Is qpdf lossless?

The structural options are: "qpdf --object-streams=generate --recompress-flate --compression-level=9" never alters a pixel or a character. It took a text-only PDF down 14.4% and image-heavy files by essentially nothing. The separate --optimize-images flag is not lossless, because it re-encodes images as JPEG, although it leaves pixel dimensions alone.

Can I use Preview’s Reduce File Size from the command line?

Yes. The filter is a plist at /System/Library/Filters/Reduce File Size.qfilter, and a nine-line Swift script can hand it to PDFKit’s write(to:withOptions:) with the QuartzFilter option. It is fixed at 144 DPI, JPEG quality 0.70 and a 2,400 pixel ceiling. It made a text-only PDF 24.2% larger in our test, so check what is in the file first.

How do I compress a PDF to an exact DPI from the Terminal?

Skip -dPDFSETTINGS and set the parameters directly: -dDownsampleColorImages=true, -dColorImageDownsampleType=/Bicubic, -dColorImageResolution=300 and, importantly, -dColorImageDownsampleThreshold=1.0 so the default 1.5 multiplier does not skip images just above your target. That took a 34.8 MB layout to 552,962 bytes with every image at exactly 300 PPI.

Does compressing a PDF from the Terminal remove metadata?

No. We checked with exiftool after every route: Title and Creator survived all of them. Ghostscript replaces Producer with its own version string and resets the creation date to the time of the run. Quartz replaces Producer with "macOS Version 27.0 … Quartz PDFContext". qpdf changes nothing at all. If you need metadata gone, treat it as a separate step and verify with pdfinfo afterwards.

Keep reading