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?
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.
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
pHYschunk for PNG intoim.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.


Top comments (0)