Short answer

Read pages with ImageSequence.Iterator(Image.open(path)), write them with pages[0].save(path, save_all=True, append_images=pages[1:], compression="tiff_lzw"). 8-bit, 16-bit greyscale and float data round-trip through NumPy exactly. For 16-bit-per-channel colour TIFFs, use the tifffile package: Pillow reduces them to 8 bits.

Read a multi-page TIFF into NumPy

read_stack.py
import numpy as np
from PIL import Image, ImageSequence

with Image.open("stack.tif") as tif:                     # e.g. a microscope z-stack
    print("pages:", tif.n_frames, " mode:", tif.mode, " size:", tif.size)
    stack = np.stack([np.asarray(page) for page in ImageSequence.Iterator(tif)])
print(stack.shape, stack.dtype, "max per page:", stack.max(axis=(1, 2)).tolist())
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
pages: 5  mode: I;16  size: (256, 256)
(5, 256, 256) uint16 max per page: [4000, 5000, 6000, 7000, 8000]

Like animated GIFs, a multi-page TIFF opens on its first page; ImageSequence.Iterator (or seek(i)) moves through the rest. Each page can have its own size and mode, so check before stacking.

Write a multi-page TIFF

write_multipage.py
from PIL import Image

pages = [Image.open("photo.jpg"), Image.open("portrait.jpg"), Image.open("logo.png")]
pages[0].save("bundle.tif", save_all=True, append_images=pages[1:], compression="tiff_lzw")

with Image.open("bundle.tif") as tif:
    for i in range(tif.n_frames):
        tif.seek(i)
        print(f"page {i}: {tif.mode:<4} {tif.size}")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
page 0: RGB  (1600, 1067)
page 1: RGB  (800, 1200)
page 2: RGBA (600, 360)

Compression

Without the compression argument, Pillow writes TIFFs uncompressed. The options compared on a photo and a 1-bit scan:

tiff_compression.py
import io
from PIL import Image

photo = Image.open("photo.jpg")
scan = photo.convert("L").point(lambda v: 255 if v > 60 else 0, mode="1")   # a 1-bit "scan"


def kb(img, compression):
    buf = io.BytesIO()
    img.save(buf, "TIFF", **({"compression": compression} if compression else {}))
    return buf.tell() / 1024


print("photo (RGB):")
for c in [None, "packbits", "tiff_lzw", "tiff_adobe_deflate", "jpeg"]:
    print(f"  {str(c):<20} {kb(photo, c):7.0f} KB")
print("scan (1-bit):")
for c in [None, "packbits", "tiff_lzw", "tiff_adobe_deflate", "group4"]:
    print(f"  {str(c):<20} {kb(scan, c):7.0f} KB")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
photo (RGB):
  None                    5002 KB
  packbits                4285 KB
  tiff_lzw                3129 KB
  tiff_adobe_deflate      1887 KB
  jpeg                     311 KB
scan (1-bit):
  None                     209 KB
  packbits                  40 KB
  tiff_lzw                  22 KB
  tiff_adobe_deflate        20 KB
  group4                    11 KB

tiff_adobe_deflate (zip) and tiff_lzw are lossless and read everywhere. jpeg is lossy and only for 8-bit RGB or greyscale photos. group4 (and the older group3) is the fax standard for 1-bit scans and compresses them far better than anything else. Match the compression to the mode: use the fax codecs only on mode 1 images and jpeg only on RGB or L, converting first if necessary. Pillow does not reliably reject other combinations with a clean error.

16-bit and float data

bit_depth.py
import numpy as np
from PIL import Image

u16 = np.linspace(0, 65535, 200 * 300).astype(np.uint16).reshape(200, 300)
f32 = np.random.default_rng(0).normal(size=(200, 300)).astype(np.float32)

Image.fromarray(u16).save("u16.tif")
Image.fromarray(f32).save("f32.tif")

for name, original in [("u16.tif", u16), ("f32.tif", f32)]:
    img = Image.open(name)
    back = np.asarray(img)
    print(f"{name}: mode {img.mode:<5} dtype {back.dtype}  identical: {np.array_equal(back, original)}")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
u16.tif: mode I;16  dtype uint16  identical: True
f32.tif: mode F     dtype float32  identical: True

Single-channel uint16 and float32 arrays survive the round trip bit for bit, as modes I;16 and F. Do not call convert("L") on them to view them: it clips instead of scaling (see cannot write mode); scale explicitly, as in the TIFF-to-JPEG example below.

The silent problem: 16-bit colour (48-bit RGB) TIFFs

Scanners, RAW converters and scientific cameras often write colour TIFFs with 16 bits per channel. Pillow has no mode for that, so it opens them as ordinary 8-bit RGB, with no warning:

rgb48.py
import numpy as np
import tifffile
from PIL import Image

rgb16 = np.random.default_rng(0).integers(0, 65536, (100, 150, 3), dtype=np.uint16)   # 16 bits per channel
tifffile.imwrite("rgb48.tif", rgb16)

via_pillow = np.asarray(Image.open("rgb48.tif"))
via_tifffile = tifffile.imread("rgb48.tif")
print("Pillow:  ", Image.open("rgb48.tif").mode, via_pillow.dtype, "max", via_pillow.max())
print("tifffile:", via_tifffile.dtype, "max", via_tifffile.max(), " identical:", np.array_equal(via_tifffile, rgb16))
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
Pillow:   RGB uint8 max 255
tifffile: uint16 max 65535  identical: True

The extra precision is gone as soon as the file is opened. If your TIFFs might be 48-bit, read them with tifffile (or imageio, which uses it) and only hand data to Pillow once it is 8-bit.

Convert TIFF to JPEG

For 8-bit TIFFs, Image.open(path).convert("RGB").save(out) is enough. 16-bit pages need scaling to 0–255 first; stretching between percentiles gives a usable preview of scientific data:

tiff_to_jpeg.py
import numpy as np
from PIL import Image, ImageSequence

with Image.open("stack.tif") as tif:
    for i, page in enumerate(ImageSequence.Iterator(tif)):
        a = np.asarray(page).astype(np.float32)
        lo, hi = np.percentile(a, (0.5, 99.5))                       # stretch contrast, ignore outliers
        out = np.clip((a - lo) / (hi - lo) * 255, 0, 255).astype(np.uint8)
        Image.fromarray(out).save(f"page_{i}.jpg", quality=90)
        print(f"page {i}: {page.mode} range {int(a.min())}-{int(a.max())} -> L 0-255")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
page 0: I;16 range 0-4000 -> L 0-255
page 1: I;16 range 0-5000 -> L 0-255
page 2: I;16 range 0-6000 -> L 0-255
page 3: I;16 range 0-7000 -> L 0-255
page 4: I;16 range 0-8000 -> L 0-255

The outputs and images on this page are the real results of running every example with Pillow 12.3.0 on Python 3.13 (Linux) on 2026-10-15. Every example is also re-run automatically on macOS (Darwin 25.6.0, Pillow 12.3.0), Linux (Linux 6.17.0-1022-azure, Pillow 12.3.0), Windows (Windows 2025Server, Pillow 12.3.0); the line under each example shows the result per operating system. The DejaVu Sans font files sit next to each script so font examples behave the same everywhere. Sample photos: NASA/Sam Lott, public domain. Spotted something wrong? Tell us.