Fix: Pillow PIL Not Working — Cannot Identify Image, Mode Errors, and EXIF Rotation
Quick Answer
How to fix Pillow PIL errors — UnidentifiedImageError cannot identify image file, OSError cannot write mode RGBA as JPEG, EXIF orientation rotated wrong, decompression bomb warning, ImageDraw text rendering, and HEIF AVIF support missing.
The Error
You open an image and Pillow refuses:
PIL.UnidentifiedImageError: cannot identify image file 'photo.heic'Or you save a PNG as JPEG and it crashes:
OSError: cannot write mode RGBA as JPEGOr photos uploaded from phones appear sideways even though they look fine in your file browser:
img = Image.open("phone_photo.jpg")
img.save("output.jpg") # Output is rotated 90°Or you load a large image and Pillow blocks it as a security risk:
PIL.Image.DecompressionBombWarning: Image size (179000000 pixels) exceeds limit
of 178956970 pixels, could be decompression bomb DOS attack.Pillow is the de facto Python image library — fast, well-maintained, and supports dozens of formats. But its mode system (RGB, RGBA, L, P), automatic EXIF handling (or lack of it), and format-specific behaviors trip up developers regularly. This guide covers each failure mode.
Why This Happens
Pillow inherited its design from PIL (Python Imaging Library), which was built around C-level image buffers with explicit modes. JPEG can only store 3 channels (RGB) — saving an RGBA image (with alpha) requires either dropping the alpha channel or compositing onto a background first. Pillow does neither automatically; it raises an error.
EXIF orientation is similarly explicit. Phones store images in the sensor’s native orientation and add an EXIF flag indicating rotation. Image viewers respect the flag; Pillow does not, unless you call ImageOps.exif_transpose(). The image data on disk hasn’t been rotated, so saving it without applying the rotation produces an output that displays correctly in some viewers and sideways in others.
Fix 1: UnidentifiedImageError — Format Not Supported
PIL.UnidentifiedImageError: cannot identify image file 'photo.heic'
PIL.UnidentifiedImageError: cannot identify image file 'photo.avif'Pillow doesn’t support HEIC (iPhone photos) or AVIF (modern web format) out of the box. Both require plugin packages.
HEIC support:
pip install pillow-heiffrom PIL import Image
import pillow_heif
pillow_heif.register_heif_opener() # Adds HEIC to Pillow
img = Image.open("phone_photo.heic")
img.save("converted.jpg")AVIF support:
pip install pillow-avif-pluginfrom PIL import Image
import pillow_avif # Auto-registers on import
img = Image.open("photo.avif")
img.save("converted.jpg")Check supported formats:
from PIL import Image
# What formats can Pillow open?
print(Image.registered_extensions())
# {'.jpg': 'JPEG', '.png': 'PNG', '.gif': 'GIF', ...}
# What formats can Pillow save?
print({ext: fmt for ext, fmt in Image.registered_extensions().items()
if fmt in Image.SAVE})WebP is built-in but requires libwebp system library:
# Linux
sudo apt install libwebp-dev
# Then reinstall Pillow to rebuild with WebP support
pip install --force-reinstall --no-binary :all: PillowMost modern Pillow wheels include WebP support by default, but conda installs and old versions may not.
Fix 2: OSError: cannot write mode RGBA as JPEG
JPEG doesn’t support transparency. Saving a PNG (with alpha channel) as JPEG fails:
from PIL import Image
img = Image.open("logo.png") # Mode: RGBA (red, green, blue, alpha)
img.save("logo.jpg") # OSError: cannot write mode RGBA as JPEGFix 1: Convert to RGB by dropping alpha:
img = Image.open("logo.png")
if img.mode == 'RGBA':
img = img.convert('RGB') # Drops alpha — transparent areas become black
img.save("logo.jpg")Fix 2: Composite onto a white background (better for logos and transparent images):
from PIL import Image
img = Image.open("logo.png")
if img.mode == 'RGBA':
# Create a white background
background = Image.new('RGB', img.size, (255, 255, 255))
# Composite the image onto the background using alpha as mask
background.paste(img, mask=img.split()[3]) # Index 3 is the alpha channel
img = background
img.save("logo.jpg", quality=90)Pro Tip: When converting transparent PNGs to JPEG for web use, always composite onto white (or your page background color). Just calling .convert('RGB') makes transparent pixels black, which looks terrible on white pages. The composite approach gives clean edges that match the surrounding design.
Mode reference:
| Mode | Channels | Use case |
|---|---|---|
'1' | 1 | Black and white (1 bit per pixel) |
'L' | 1 | Grayscale (8 bit per pixel) |
'P' | 1 | Palette indexed color (GIF, some PNGs) |
'RGB' | 3 | True color, no transparency |
'RGBA' | 4 | True color with alpha (transparency) |
'CMYK' | 4 | Print color (cyan, magenta, yellow, key) |
Convert palette mode (P) to RGB — common for GIFs:
img = Image.open("animated.gif")
print(img.mode) # 'P'
img.convert('RGB').save("first_frame.jpg")Fix 3: EXIF Rotation — Photos Appear Sideways
Phone cameras save images in the sensor’s native orientation (often landscape) and add an EXIF tag (1–8) indicating how to rotate for correct display.
from PIL import Image
img = Image.open("phone_portrait.jpg")
print(img.size) # (4032, 3024) — looks landscape, even though shot in portrait
img.save("output.jpg") # Output is sideways!Fix — apply EXIF orientation before any processing:
from PIL import Image, ImageOps
img = Image.open("phone_portrait.jpg")
# Apply EXIF rotation and remove the EXIF orientation tag
img = ImageOps.exif_transpose(img)
print(img.size) # (3024, 4032) — now correctly oriented
img.save("output.jpg")Always call exif_transpose() immediately after opening if you’re processing or displaying user-uploaded photos. Otherwise:
- Cropping uses the wrong region
- Resizing produces stretched output
- Display shows sideways or upside-down
Inspect EXIF data:
from PIL import Image
from PIL.ExifTags import TAGS
img = Image.open("photo.jpg")
exif = img._getexif()
if exif is not None:
for tag_id, value in exif.items():
tag_name = TAGS.get(tag_id, tag_id)
print(f"{tag_name}: {value}")
# Look for 'Orientation': 6 (rotated 90° CW), 8 (90° CCW), 3 (180°)Strip EXIF data for privacy (removes location, camera info):
img = Image.open("photo.jpg")
img = ImageOps.exif_transpose(img) # Apply rotation first
# Save without EXIF
img.save("clean.jpg", exif=b"")
# Or with explicit metadata stripping
data = list(img.getdata())
clean_img = Image.new(img.mode, img.size)
clean_img.putdata(data)
clean_img.save("clean.jpg")Fix 4: DecompressionBombWarning and OSError
DecompressionBombWarning: Image size (179000000 pixels) exceeds limit of
178956970 pixels, could be decompression bomb DOS attack.Pillow blocks images above ~178 megapixels by default to prevent denial-of-service attacks via crafted images that decompress to enormous sizes.
For trusted images (your own photos), increase the limit:
from PIL import Image
# Increase limit (in pixels)
Image.MAX_IMAGE_PIXELS = 500_000_000 # 500 megapixels
# Or disable entirely (not recommended for user uploads)
Image.MAX_IMAGE_PIXELS = NoneFor user uploads, validate dimensions before processing:
from PIL import Image
def safe_open(path, max_pixels=50_000_000):
"""Open image with size validation."""
with Image.open(path) as img:
pixel_count = img.size[0] * img.size[1]
if pixel_count > max_pixels:
raise ValueError(f"Image too large: {pixel_count} pixels")
return img.copy() # Return a copy so the with-block can close
img = safe_open("user_upload.jpg", max_pixels=50_000_000)Image.open is lazy — it reads the header but doesn’t decode pixel data until you access .size, .load(), or perform an operation. To validate size cheaply, just check .size first.
Fix 5: Saving with Quality and Optimization
JPEG quality and PNG optimization aren’t intuitive — defaults are often suboptimal.
JPEG quality:
from PIL import Image
img = Image.open("photo.png").convert('RGB')
# Default quality is 75 — usually too low for production
img.save("default.jpg") # quality=75
# Higher quality, larger file
img.save("high.jpg", quality=95)
# Web-optimized
img.save(
"optimized.jpg",
quality=85,
optimize=True, # Slower save, smaller file
progressive=True, # Loads progressively in browsers
)Quality recommendations:
| Use case | Recommended quality |
|---|---|
| Thumbnails | 70–80 |
| Web hero images | 80–85 |
| Photo galleries | 85–90 |
| Print/archive | 95+ |
PNG compression:
img = Image.open("logo.png")
# PNG compression level 0–9 (default 6)
img.save("compressed.png", compress_level=9) # Slowest but smallest
# Optimize: reorder palette and find best filter (slow but smaller files)
img.save("optimized.png", optimize=True)WebP — better compression than JPEG with transparency support:
img = Image.open("photo.png")
# Lossy WebP (smaller than JPEG at same quality)
img.save("photo.webp", quality=80)
# Lossless WebP (smaller than PNG)
img.save("logo.webp", lossless=True)Fix 6: Resize, Thumbnail, and Aspect Ratio
from PIL import Image
img = Image.open("photo.jpg")
print(img.size) # (4032, 3024)
# resize — exact dimensions, can distort aspect ratio
resized = img.resize((800, 800)) # Squashes 4032x3024 into 800x800
# Maintain aspect ratio manually
target_width = 800
ratio = target_width / img.size[0]
target_height = int(img.size[1] * ratio)
resized = img.resize((target_width, target_height))
# thumbnail — modifies in place, maintains aspect ratio
img = Image.open("photo.jpg")
img.thumbnail((800, 800)) # Fits within 800x800, preserves aspect ratio
print(img.size) # (800, 600) — height adjusted to maintain ratioResampling filter affects quality:
from PIL import Image
img = Image.open("photo.jpg")
# Resampling filters (fastest to highest quality)
img.resize((800, 600), Image.NEAREST) # Fastest, blocky
img.resize((800, 600), Image.BILINEAR)
img.resize((800, 600), Image.BICUBIC)
img.resize((800, 600), Image.LANCZOS) # Highest quality, slowest (recommended)In Pillow 10.0+, the constants are namespaced: Image.Resampling.LANCZOS. Both forms work for backward compatibility, but use the new form in new code:
img.resize((800, 600), Image.Resampling.LANCZOS)Common Mistake: Using NEAREST resampling for photo thumbnails. It’s blocky and looks terrible. LANCZOS is the default for Image.thumbnail() and the right choice for downscaling photos. Only use NEAREST for pixel art or when you specifically want to preserve hard edges (favicons, palette images).
Fix 7: Drawing and Text
from PIL import Image, ImageDraw, ImageFont
img = Image.new('RGB', (800, 600), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
# Shapes
draw.rectangle([(50, 50), (200, 200)], fill='blue', outline='black', width=3)
draw.ellipse([(300, 50), (450, 200)], fill='red')
draw.line([(0, 0), (800, 600)], fill='green', width=2)
# Text — needs a font
font = ImageFont.truetype("DejaVuSans-Bold.ttf", 48) # Linux
# font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 48) # macOS
# font = ImageFont.truetype("C:/Windows/Fonts/Arial.ttf", 48) # Windows
draw.text((50, 300), "Hello PIL", font=font, fill='black')
img.save("drawing.png")ImageFont.load_default() is the fallback if you don’t have a font file — but it’s tiny (10pt). Always use truetype for production:
# Find available fonts on the system
import matplotlib.font_manager as fm
fonts = fm.findSystemFonts(fontpaths=None, fontext='ttf')
for f in fonts[:5]:
print(f)Multi-line text and text wrapping:
from PIL import Image, ImageDraw, ImageFont
import textwrap
img = Image.new('RGB', (600, 400), 'white')
draw = ImageDraw.Draw(img)
font = ImageFont.truetype("DejaVuSans.ttf", 24)
text = "This is a long sentence that needs to wrap across multiple lines for readability."
wrapped = textwrap.fill(text, width=30) # 30 chars per line
draw.multiline_text((20, 20), wrapped, font=font, fill='black', spacing=8)
img.save("wrapped.png")Measure text size (for centering or layout):
font = ImageFont.truetype("DejaVuSans.ttf", 48)
text = "Hello"
# Pillow 10.0+
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Center the text in the image
x = (img.width - text_width) // 2
y = (img.height - text_height) // 2
draw.text((x, y), text, font=font, fill='black')textsize() was deprecated in Pillow 9.2 and removed in Pillow 10.0 — use textbbox() instead.
Fix 8: Animated GIFs and Multi-Frame Images
Read animated GIFs:
from PIL import Image
img = Image.open("animation.gif")
print(img.is_animated) # True
print(img.n_frames) # Number of frames
# Iterate frames
for frame_num in range(img.n_frames):
img.seek(frame_num)
img.copy().convert('RGB').save(f"frame_{frame_num:04d}.png")Create animated GIF from frames:
from PIL import Image
import os
frames = [Image.open(f"frame_{i:04d}.png") for i in range(30)]
# Save as animated GIF
frames[0].save(
"output.gif",
save_all=True,
append_images=frames[1:],
duration=100, # ms per frame
loop=0, # 0 = infinite loop, 1 = play once
optimize=True,
)Create animated WebP (smaller file size, same quality):
frames[0].save(
"output.webp",
save_all=True,
append_images=frames[1:],
duration=100,
loop=0,
quality=80,
method=6, # Higher = better compression, slower (0–6)
)Still Not Working?
Pillow vs OpenCV — When to Use Which
- Pillow (PIL): Better for general image manipulation, drawing, format conversion, web use. Cleaner Python API. RGB by default.
- OpenCV (cv2): Better for computer vision, real-time processing, video. C++ performance. BGR by default.
Both can convert images between each other:
from PIL import Image
import numpy as np
import cv2
# PIL → OpenCV
pil_img = Image.open("photo.jpg")
cv2_img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
# OpenCV → PIL
cv2_img = cv2.imread("photo.jpg")
pil_img = Image.fromarray(cv2.cvtColor(cv2_img, cv2.COLOR_BGR2RGB))For OpenCV-specific patterns and BGR/RGB confusion, see OpenCV not working.
Integration with NumPy
Pillow images convert to NumPy arrays for any pixel-level math:
from PIL import Image
import numpy as np
img = Image.open("photo.jpg")
arr = np.array(img)
print(arr.shape) # (height, width, 3) for RGB
# Brightness
bright = np.clip(arr.astype(np.int16) + 50, 0, 255).astype(np.uint8)
img_bright = Image.fromarray(bright)
img_bright.save("bright.jpg")For NumPy array shape, dtype, and broadcasting issues that affect image processing, see NumPy not working.
Batch Processing
from PIL import Image, ImageOps
from pathlib import Path
input_dir = Path("photos")
output_dir = Path("processed")
output_dir.mkdir(exist_ok=True)
for img_path in input_dir.glob("*.jpg"):
with Image.open(img_path) as img:
img = ImageOps.exif_transpose(img) # Apply rotation
img.thumbnail((1920, 1920)) # Limit max dimension
img.save(output_dir / img_path.name, quality=85, optimize=True)Serving Images in Web Apps
For displaying processed images in Streamlit apps with st.image() and avoiding the BytesIO buffer pitfalls, see Streamlit not working.
Display in Matplotlib and Jupyter
import matplotlib.pyplot as plt
from PIL import Image
img = Image.open("photo.jpg")
plt.imshow(img)
plt.axis('off')
plt.show()For Matplotlib backend and display issues in Jupyter notebooks, see Matplotlib not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: OpenCV Not Working — imread Returns None, imshow Crash, and Color Channel Errors
How to fix OpenCV errors — cv2.imread returns None, imshow crashes on headless server, BGR vs RGB color mismatch, cannot open video capture, ImportError libGL not found, resize interpolation, and cv2.error assertion failed.
Fix: Apache Airflow Not Working — DAG Not Found, Task Failures, and Scheduler Issues
How to fix Apache Airflow errors — DAG not appearing in UI, ImportError preventing DAG load, task stuck in running or queued, scheduler not scheduling, XCom too large, connection not found, and database migration errors.
Fix: BeautifulSoup Not Working — Parser Errors, Encoding Issues, and find_all Returns Empty
How to fix BeautifulSoup errors — bs4.FeatureNotFound install lxml, find_all returns empty list, Unicode decode error, JavaScript-rendered content not found, select vs find_all confusion, and slow parsing on large HTML.
Fix: Dash Not Working — Callback Errors, Pattern Matching, and State Management
How to fix Dash errors — circular dependency in callbacks, pattern matching callback not firing, missing attribute clientside_callback, DataTable filtering not working, clientside JavaScript errors, Input Output State confusion, and async callback delays.