Short answer: an image "is 300 DPI" for a given print size only if its pixel dimensions are at least inches × 300 on both sides. The DPI number stored in the file is a hint for layout software; it does not add pixels. So the reliable check is arithmetic on width × height, and the DPI tag is something you report, not something you trust.

This post builds a small Pillow checker, runs it against a real AI-generated festival poster, and pins the behaviour down with tests. Everything here is reproducible with Python 3 and Pillow.

The fixture: a real "print-ready" poster

The test image is the NIGHT PULSE festival poster from Voor's GPT Image 2 prompt library. Its prompt literally asks for "print-ready", which makes it a good test case: does the word in the prompt translate into enough pixels for print?

Real 1024 x 1024 NIGHT PULSE poster generated with GPT Image 2, annotated with the checker result: no DPI metadata, 3.41 inches maximum at 300 DPI, 128 effective PPI at 8 by 8 inches

The downloaded PNG is 1024 × 1024 px and carries no DPI metadata at all.

The rule in one line

required_px = ceil(print_inches × dpi) # per side effective_ppi = min(width_px / width_in, height_px / height_in) 

Use ceil, not round: a 3.4166-inch side at 300 DPI needs 1025 pixels, and 1024 is one pixel short. Use the weaker side for effective PPI, because a square image placed on a 4 × 6 in card is limited by its 6-inch side.

The checker

"""Check whether an image has enough pixels to print at a target size and DPI.""" from __future__ import annotations import argparse import json import math from dataclasses import asdict, dataclass from pathlib import Path from PIL import Image @dataclass(frozen=True) class PrintCheck: width_px: int height_px: int metadata_dpi: tuple[float, float] | None target_in: tuple[float, float] required_px: tuple[int, int] effective_ppi: float max_in_at_target_dpi: tuple[float, float] passes: bool def check_print(path: str | Path, width_in: float, height_in: float, dpi: int = 300) -> PrintCheck: if width_in <= 0 or height_in <= 0 or dpi <= 0: raise ValueError("print size and dpi must be positive") with Image.open(path) as im: width_px, height_px = im.size meta = im.info.get("dpi") required = (math.ceil(width_in * dpi), math.ceil(height_in * dpi)) effective = min(width_px / width_in, height_px / height_in) return PrintCheck( width_px=width_px, height_px=height_px, metadata_dpi=tuple(round(float(v), 2) for v in meta) if meta else None, target_in=(width_in, height_in), required_px=required, effective_ppi=round(effective, 1), max_in_at_target_dpi=(round(width_px / dpi, 2), round(height_px / dpi, 2)), passes=width_px >= required[0] and height_px >= required[1], ) def tag_dpi(src: str | Path, dst: str | Path, dpi: int = 300) -> None: """Write DPI metadata without resampling. This never adds detail.""" with Image.open(src) as im: im.save(dst, dpi=(dpi, dpi)) def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("image") ap.add_argument("--size", required=True, help="print size in inches, e.g. 8x10") ap.add_argument("--dpi", type=int, default=300) args = ap.parse_args() w, h = (float(v) for v in args.size.lower().split("x")) result = check_print(args.image, w, h, args.dpi) print(json.dumps(asdict(result), indent=2)) return 0 if result.passes else 1 if __name__ == "__main__": raise SystemExit(main()) 

Run it from the command line:

pip install pillow pytest python dpi_check.py fixtures/night-pulse-poster.png --size 8x8 

Output for the real poster:

{ "width_px": 1024, "height_px": 1024, "metadata_dpi": null, "target_in": [8.0, 8.0], "required_px": [2400, 2400], "effective_ppi": 128.0, "max_in_at_target_dpi": [3.41, 3.41], "passes": false } 

The exit code is 1 when the check fails, so you can drop it into a CI step or a pre-upload script for a print shop.

Why "just set it to 300 DPI" does not work

tag_dpi() writes a 300 DPI tag without resampling. The test below proves the pixel data is byte-for-byte identical afterwards and the 8 × 8 in check still fails. That is exactly what the "change DPI" box in many editors does when resampling is off.

Nearest-neighbour zoom of the small lineup text from the same poster at 2x and at a further 2.34x, the stretch an 8 inch print at 300 DPI needs

The crop above is the small lineup text from the same poster. At 8 × 8 inches each source pixel has to cover about 2.34 printer dots per side, which is where small type starts to look soft. Only a larger render or careful upscaling adds pixels; a metadata tag cannot.

Tests

from pathlib import Path import pytest from PIL import Image from dpi_check import check_print, tag_dpi POSTER = Path(__file__).parent / "fixtures" / "night-pulse-poster.png" def test_fixture_has_no_dpi_metadata(): assert check_print(POSTER, 3, 3).metadata_dpi is None def test_poster_fails_8x8_at_300_dpi(): r = check_print(POSTER, 8, 8) assert (r.width_px, r.height_px) == (1024, 1024) assert r.required_px == (2400, 2400) assert r.effective_ppi == 128.0 assert not r.passes def test_poster_passes_at_its_true_300_dpi_size(): r = check_print(POSTER, 3.4, 3.4) assert r.required_px == (1020, 1020) assert r.passes assert r.max_in_at_target_dpi == (3.41, 3.41) def test_boundary_rounds_required_pixels_up(): r = check_print(POSTER, 1025 / 300, 1) assert r.required_px[0] == 1025 assert not r.passes def test_mismatched_aspect_uses_the_weaker_side(): r = check_print(POSTER, 4, 6) assert r.effective_ppi == pytest.approx(170.7) assert not r.passes def test_tagging_300_dpi_changes_metadata_not_pixels(tmp_path): out = tmp_path / "tagged.png" tag_dpi(POSTER, out) before, after = check_print(POSTER, 8, 8), check_print(out, 8, 8) assert after.metadata_dpi == (300.0, 300.0) with Image.open(POSTER) as a, Image.open(out) as b: assert a.tobytes() == b.tobytes() assert before.passes == after.passes is False def test_large_synthetic_image_passes_8x10(tmp_path): big = tmp_path / "big.png" Image.new("RGB", (2400, 3000), "white").save(big) assert check_print(big, 8, 10).passes @pytest.mark.parametrize("w,h,dpi", [(0, 1, 300), (1, -2, 300), (1, 1, 0)]) def test_rejects_non_positive_inputs(w, h, dpi): with pytest.raises(ValueError): check_print(POSTER, w, h, dpi) 
$ pytest -q .......... [100%] 10 passed in 1.20s 

The eight test functions (ten cases with the parametrized one) cover: missing metadata, the failing 8 × 8 case, the passing 3.4 in case, the ceil boundary, aspect mismatch, metadata-only tagging, a large synthetic pass and input validation.

Edge cases worth knowing

  • JPEG vs PNG: Pillow reads JFIF/EXIF density for JPEG and the pHYs chunk for PNG into im.info["dpi"]. Some exports store density in centimetres or with rounding (e.g. 299.9994); round before comparing if you assert on metadata.
  • No metadata: many tools assume 72 or 96 DPI when the tag is missing. That assumption only affects the default placed size in a layout app, not print quality.
  • Bleed: add the bleed to the print size before checking. An 8 × 8 in poster with 0.125 in bleed is 8.25 × 8.25 in, which needs 2475 px per side.
  • Vector-looking art is still raster: a flat "print-ready" AI image is a PNG; scaling it up does not behave like SVG.

Getting a fixture of your own

If you want to run the same check on your own generated poster, the GPT Image 2 prompt workspace has the festival prompt in its library. When I checked on October 26, 2026, the generator defaulted to 3:4, one output and Low quality, which showed 5 credits; switching to High showed 45 credits for one image. Measure whatever file you download with the script instead of assuming its size. New creations are Public with a watermark by default.

Wrap-up

Check pixels, not tags: ceil(inches × 300) per side, using the weaker side when aspect ratios differ. Keep the DPI tag for layout convenience, and fail the build when the pixels are short. If you need a test poster to try it on, generate one from the same prompt library and run the checker on the download.