Pillow’s Image.save() is the call most people need when they want to save images in Python: pass a file name and the extension (.png, .jpg, .webp) picks the format. Charts use Matplotlib’s savefig(), and NumPy arrays use OpenCV’s cv2.imwrite():
from PIL import Image
img = Image.new("RGB", (800, 300), "navy")
img.save("banner.png") # .png -> PNG, .jpg -> JPEG
My test machine for this page ran Windows 11 with Python 3.12.5, Pillow 12.3.0, Matplotlib 3.11.2, OpenCV 5.0.0 and requests 2.34.2, and I opened each saved file afterward to confirm what actually landed on disk.
In this tutorial, we’ll save images with Pillow, put them in dated folders, export charts, write OpenCV arrays, download pictures from a URL, keep images in memory, and compare file sizes across formats.
How do you save an image in Python with Pillow?
Pillow is the maintained fork of the old PIL library, and it’s the easiest place to start. If you haven’t set things up yet, these steps get you from nothing to a saved file:
- Open Command Prompt and install the libraries:
pip install pillow matplotlib opencv-python requests. - Create a folder for the project and save the code below in it as
save_banner.py. - Run
python save_banner.pyfrom that folder. The images appear next to the script.
This script draws a store sale banner, saves it in three formats, and opens each file again to check the format and size:
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
banner = Image.new("RGB", (800, 300), "#0b3d91")
draw = ImageDraw.Draw(banner)
draw.text((40, 60), "Fall Sale: 25% Off", font=ImageFont.load_default(size=64), fill="white")
draw.text((40, 175), "Seattle store | Oct 24 - Nov 2, 2026",
font=ImageFont.load_default(size=32), fill="#ffd43b")
banner.save("fall_sale_banner.png") # format comes from .png
banner.save("fall_sale_banner.jpg", quality=90) # JPEG with a quality setting
banner.save("fall_sale_banner.webp", quality=80)
for name in ("fall_sale_banner.png", "fall_sale_banner.jpg", "fall_sale_banner.webp"):
with Image.open(name) as check:
size = Path(name).stat().st_size
print(f"{name:<22} {check.format:<5} {check.size} {size:>7,} bytes")
Output:
fall_sale_banner.png PNG (800, 300) 19,451 bytes
fall_sale_banner.jpg JPEG (800, 300) 26,288 bytes
fall_sale_banner.webp WEBP (800, 300) 10,938 bytes

Here’s the PNG it produced. Flat colors and text like this suit PNG: it keeps letter edges crisp, and here it even came out smaller than the JPEG:

You didn’t pass a format, because Pillow reads it from the extension. You can still force one with img.save("banner.dat", format="PNG"). Converting a whole folder of files works the same way; I built a full JPG to PNG converter in Python on top of this call.
Save a transparent image as JPEG
JPEG has no alpha channel, so a transparent RGBA image can’t be saved as .jpg directly. Convert it first, or keep it as PNG:
from PIL import Image
logo = Image.new("RGBA", (400, 200), (255, 255, 255, 0)) # transparent background
try:
logo.save("store_logo.jpg")
except OSError as err:
print("JPEG failed:", err)
logo.convert("RGB").save("store_logo.jpg") # drop the alpha channel first
logo.save("store_logo.png") # PNG keeps the transparency
print("store_logo.jpg mode:", Image.open("store_logo.jpg").mode)
print("store_logo.png mode:", Image.open("store_logo.png").mode)
Output:
JPEG failed: cannot write mode RGBA as JPEG
store_logo.jpg mode: RGB
store_logo.png mode: RGBA
Watch for this when you open a PNG logo, resize it, and save it as JPEG. The fix is always .convert("RGB") before the save.
Save an image to a directory in Python with os.makedirs()
Saving into a folder that doesn’t exist yet raises FileNotFoundError, so create it first. os.makedirs(..., exist_ok=True) builds every missing level and stays quiet if the folder’s already there:
import os
from datetime import date
from PIL import Image
products = {"mug": "#c0392b", "tote-bag": "#2e86c1", "ball-cap": "#239b56"}
folder = os.path.join("exports", date(2026, 10, 24).isoformat())
os.makedirs(folder, exist_ok=True) # creates exports\2026-10-24, no error if it exists
for name, color in products.items():
thumb = Image.new("RGB", (300, 300), color)
path = os.path.join(folder, f"{name}-300x300.png")
thumb.save(path)
print("saved", path)
Output:
saved exports\2026-10-24\mug-300x300.png
saved exports\2026-10-24\tote-bag-300x300.png
saved exports\2026-10-24\ball-cap-300x300.png
ball-cap-300x300.png
mug-300x300.png
tote-bag-300x300.png

dir /b lists the three PNG files.I like a date in the folder name for daily exports, because reruns land beside the old files instead of overwriting them. More folder options are in how to create a directory in Python.
If you prefer pathlib, the same idea is mkdir(parents=True, exist_ok=True), and Pillow accepts the Path object as is:
from pathlib import Path
from PIL import Image
out_dir = Path("exports") / "receipts"
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / "receipt-1042.png"
Image.new("RGB", (400, 600), "white").save(target)
print(target, "exists:", target.exists())
Output:
exports\receipts\receipt-1042.png exists: True
Save a Matplotlib plot as an image file with savefig()
A chart isn’t a pixel array, it’s a figure, so you save it with savefig(). Call it before plt.show(), set dpi for sharpness, and use bbox_inches="tight" so labels aren’t cut off:
import matplotlib.pyplot as plt
from pathlib import Path
from PIL import Image
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"]
sales = [18250.40, 19980.15, 22410.75, 21095.30, 24870.90, 26315.25, 25190.60, 27740.85, 29105.50]
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.bar(months, sales, color="#2e86c1")
ax.set_title("Austin store sales, 2026 (USD)")
ax.set_ylabel("Sales")
ax.yaxis.set_major_formatter(lambda v, _: f"${v:,.0f}")
fig.savefig("austin_sales_2026.png", dpi=150, bbox_inches="tight") # save BEFORE show()
fig.savefig("austin_sales_2026.svg")
with Image.open("austin_sales_2026.png") as png:
print("PNG pixels:", png.size)
print("SVG bytes:", f"{Path('austin_sales_2026.svg').stat().st_size:,}")
plt.show()
Output:
PNG pixels: (1088, 616)
SVG bytes: 31,155

At 150 dpi, an 8 x 4.5 inch figure would be 1,200 x 675 pixels; the tight bounding box trimmed the margins to 1,088 x 616. SVG stays sharp at any zoom. For transparent backgrounds and batch exports, see saving a Matplotlib chart as PNG.
If you call savefig() after closing the window, you’ll often get an empty file. That one’s covered in why Matplotlib savefig() saves a blank image.
Save a NumPy array as an image with OpenCV cv2.imwrite()
OpenCV images are NumPy arrays of shape (height, width, 3), so a 3D NumPy array is all you need. cv2.imwrite(path, array) writes it and returns True on success:
import cv2
import numpy as np
label = np.full((300, 700, 3), 255, dtype=np.uint8) # white: 300 rows x 700 columns
cv2.rectangle(label, (20, 20), (680, 280), (0, 0, 255), 6) # BGR order: (0, 0, 255) is red
cv2.putText(label, "SKU 40817 $24.99", (50, 170), cv2.FONT_HERSHEY_SIMPLEX, 1.6, (0, 0, 0), 3)
ok_png = cv2.imwrite("price_label.png", label)
ok_jpg = cv2.imwrite("price_label.jpg", label, [cv2.IMWRITE_JPEG_QUALITY, 85])
print("PNG saved:", ok_png, "| JPEG saved:", ok_jpg)
back = cv2.imread("price_label.png")
print("Read back:", back.shape, back.dtype, "| border pixel (B, G, R):", back[20, 300])
Output:
PNG saved: True | JPEG saved: True
Read back: (300, 700, 3) uint8 | border pixel (B, G, R): [ 0 0 255]

Notice the pixel came back as [0 0 255]. OpenCV stores channels as blue, green, red, so an RGB array from Pillow or Matplotlib needs converting before you save it:
import cv2
import numpy as np
from PIL import Image
rgb = np.array(Image.new("RGB", (200, 100), (255, 120, 0))) # orange, in RGB order
cv2.imwrite("orange_wrong.png", rgb)
cv2.imwrite("orange_right.png", cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR))
print("without cvtColor:", Image.open("orange_wrong.png").getpixel((0, 0)))
print("with cvtColor: ", Image.open("orange_right.png").getpixel((0, 0)))
Output:
without cvtColor: (0, 120, 255)
with cvtColor: (255, 120, 0)
The first file swapped red and blue, turning orange into blue. One cv2.cvtColor(..., cv2.COLOR_RGB2BGR) call fixes it.
imwrite() also fails quietly. A missing folder only returns False, and it never adds an extension for you; a name without one raises an error:
import cv2
import numpy as np
img = np.zeros((50, 50, 3), dtype=np.uint8)
print("missing folder ->", cv2.imwrite("no_such_folder/test.png", img))
try:
cv2.imwrite("test_without_extension", img)
except cv2.error as err:
print("no extension ->", str(err).split("error: ")[-1].strip())
Output:
missing folder -> False
no extension -> (-2:Unspecified error) could not find a writer for the specified extension in function 'cv::imwrite_'
So check the return value every time, and create the folder first, just like with Pillow.
Download an image from a URL and save it in Python
You don’t need an image library just to save a picture from the web. Fetch it with requests and write the bytes to a file opened in binary mode; Python with open() explains those file modes:
import requests
from PIL import Image
url = "https://www.python.org/static/img/python-logo.png"
response = requests.get(url, timeout=15)
response.raise_for_status() # stop here on a 404 or 500
print("Status:", response.status_code, "| Type:", response.headers["Content-Type"])
with open("python-logo.png", "wb") as f: # "wb" = write bytes
f.write(response.content)
with Image.open("python-logo.png") as img:
print(f"Saved {len(response.content):,} bytes:", img.format, img.size)
Output:
Status: 200 | Type: image/png
Saved 15,770 bytes: PNG (580, 164)

raise_for_status() matters more than it looks. Image links die all the time, and without it you’d happily save a 404 HTML page as a .jpg. Here’s an old government image URL that’s gone:
import requests
url = "https://www.nasa.gov/sites/default/files/images/730631main_1673_full.jpg"
try:
r = requests.get(url, timeout=15)
r.raise_for_status()
except requests.HTTPError as err:
print("Not saved:", err.response.status_code, err.response.reason)
Output:
Not saved: 404 Not Found
When you’re pulling dozens of product photos from a page, the same loop sits inside a scraper; web scraping with Python shows how to collect the image URLs first.
For big files, stream the download so the whole image never sits in memory at once:
import requests
url = "https://www.python.org/static/img/python-logo.png"
total = 0
with requests.get(url, stream=True, timeout=15) as r:
r.raise_for_status()
with open("python-logo-stream.png", "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
total += f.write(chunk)
print(f"Wrote {total:,} bytes in 8 KB chunks")
Output:
Wrote 15,770 bytes in 8 KB chunks
If you’d rather avoid third-party packages, the standard library’s urlretrieve() does the same in one line:
from urllib.request import urlretrieve
path, headers = urlretrieve("https://www.python.org/static/img/python-logo.png", "logo-urllib.png")
print(path, headers["Content-Type"], headers["Content-Length"], "bytes")
Output:
logo-urllib.png image/png 15770 bytes
Save an image to memory with BytesIO instead of a file
Web apps and API uploads often want the image as bytes, not a file on disk. Save into a BytesIO buffer, but name the format, since there’s no extension to read:
from io import BytesIO
from PIL import Image
badge = Image.new("RGB", (240, 80), "#239b56")
buffer = BytesIO()
try:
badge.save(buffer) # no file name, so no extension to read
except ValueError as err:
print("No format given:", err)
badge.save(buffer, format="PNG")
data = buffer.getvalue()
print(len(data), "bytes in memory, starts with", data[:8])
Output:
No format given: unknown file extension:
302 bytes in memory, starts with b'\x89PNG\r\n\x1a\n'
Those first bytes are the PNG signature, so the data’s ready to send as is. Reading such bytes back is the same as reading a binary file in Python.
Which image format should you save in: PNG, JPEG or WebP?
Format choice changes file size far more than code does. I saved one 900 x 600 photo-like image (a color gradient with grain) in six formats:
import os
import numpy as np
from PIL import Image
rng = np.random.default_rng(2026)
y, x = np.mgrid[0:600, 0:900]
photo = np.dstack([x / 900 * 255, y / 600 * 255, np.full(x.shape, 140.0)])
photo += rng.normal(0, 12, photo.shape) # grain, like a real photo
img = Image.fromarray(np.clip(photo, 0, 255).astype(np.uint8))
for name, opts in [("photo.png", {}), ("photo_q95.jpg", {"quality": 95}),
("photo_q75.jpg", {"quality": 75}), ("photo.webp", {"quality": 80}),
("photo.bmp", {}), ("photo.gif", {})]:
img.save(name, **opts)
print(f"{name:<14} {os.path.getsize(name) / 1024:>8.1f} KB")
Output:
photo.png 1217.9 KB
photo_q95.jpg 292.6 KB
photo_q75.jpg 84.2 KB
photo.webp 120.6 KB
photo.bmp 1582.1 KB
photo.gif 394.1 KB
| Format | File | Size | Use it for |
|---|---|---|---|
| PNG | photo.png | 1217.9 KB | Screenshots, logos, text, transparency (lossless) |
| JPEG, quality 95 | photo_q95.jpg | 292.6 KB | Photos where detail matters |
| JPEG, quality 75 | photo_q75.jpg | 84.2 KB | Web photos (Pillow’s default quality) |
| WebP, quality 80 | photo.webp | 120.6 KB | Modern websites, transparency supported |
| BMP | photo.bmp | 1582.1 KB | Uncompressed, rarely worth it |
| GIF | photo.gif | 394.1 KB | Simple graphics and animation (256 colors) |
Grainy photos are PNG’s worst case: it’s close to the uncompressed BMP, while JPEG at quality 75 is about 14 times smaller than the PNG. For photos, lower JPEG quality until you see artifacts; my image compressor in Python automates that tradeoff.
Why does saving an image fail in Python?
Most save failures come down to three messages. This loop triggers each one with Pillow:
from PIL import Image
img = Image.new("RGB", (100, 100), "white")
for target in ("report/chart.png", "chart.jpeg2", "chart"):
try:
img.save(target)
except (FileNotFoundError, ValueError) as err:
print(f"{target:<17} {type(err).__name__}: {err}")
Output:
report/chart.png FileNotFoundError: [Errno 2] No such file or directory: 'report/chart.png'
chart.jpeg2 ValueError: unknown file extension: .jpeg2
chart ValueError: unknown file extension:
- FileNotFoundError: the folder doesn’t exist. Create it with
os.makedirs(folder, exist_ok=True). - ValueError: unknown file extension: a typo or missing extension. Fix the name or pass
format="PNG"; getting a file extension in Python helps you check it first. - OSError: cannot write mode RGBA as JPEG: call
.convert("RGB")or save as PNG. - cv2.imwrite() returns False: a missing folder or a path you can’t write to.
To avoid overwriting someone’s file by accident, check if a file exists in Python before you save. Saved files ending up somewhere unexpected? Relative names land in the folder you ran Python from, not the script’s folder.
Want to take these saved images further? Try one of these next:
- Capture screenshots in Python
- Build an image to PDF converter in Python
- Save a Matplotlib figure as PDF
Frequently asked questions
How do I save an image to a file in Python?
Open or create it with Pillow and call img.save("name.png"). The extension sets the format, and you can pass format= to override it.
How do I save an image to a specific folder in Python?
Create the folder with os.makedirs(folder, exist_ok=True), then save to os.path.join(folder, "name.png"). Pillow and OpenCV both need the folder to exist.
Why does cv2.imwrite() return False?
Usually the target folder doesn’t exist or isn’t writable. OpenCV doesn’t raise an error for that, so always check the returned value.
Why are my colors wrong after saving with OpenCV?
OpenCV expects BGR order. Convert RGB arrays with cv2.cvtColor(img, cv2.COLOR_RGB2BGR) before calling cv2.imwrite().
Should I save a Matplotlib chart before or after plt.show()?
Before. Call fig.savefig() first; after the window closes the figure may be empty and you’ll save a blank image.
Where are Pillow’s save options documented?
Each format’s options, such as JPEG quality and PNG optimize, are listed in the Pillow Image.save() reference.

Bijay Kumar is a 13-time Microsoft MVP with more than 18 years in software development, and the founder of Python Guides and TSinfo Technologies. He started out building .NET and SharePoint solutions at HP, TCS and KPIT before moving into Python, machine learning and AI, and he also builds web apps with TypeScript and React. He writes the tutorials here himself, and every example is run before publishing so you see the real output. More about Bijay · Microsoft MVP profile · LinkedIn