How to Save Images in Python (Pillow, OpenCV, Matplotlib)

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:

  1. Open Command Prompt and install the libraries: pip install pillow matplotlib opencv-python requests.
  2. Create a folder for the project and save the code below in it as save_banner.py.
  3. Run python save_banner.py from 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
Command Prompt output after Pillow saved a sale banner as PNG, JPEG and WebP with file sizes
Pillow wrote the same 800 x 300 banner as PNG, JPEG and WebP; reopening each file confirms the format it was saved in.

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:

The fall sale banner PNG file created by Pillow's Image.save() in Python
The saved fall_sale_banner.png, opened from disk.

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
Command Prompt showing Python saving three product thumbnails into exports\2026-10-24 and the dir listing of that folder
The script creates exports\2026-10-24, saves three thumbnails into it, and 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
Matplotlib plot window showing an Austin store 2026 sales bar chart that Python saved with savefig()
The bar chart in its plot window after savefig() wrote the PNG and SVG copies to disk.

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]
Command Prompt output of OpenCV cv2.imwrite saving a price label as PNG and JPEG and reading it back
Both writes return True, and reading the PNG back gives the same 300 x 700 array with a red border pixel stored as B, G, R.

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)
Command Prompt output of Python requests downloading the python.org logo and saving it as a PNG file
requests gets a 200 response with an image/png type, the bytes go to python-logo.png, and Pillow confirms it’s a valid PNG.

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
FormatFileSizeUse it for
PNGphoto.png1217.9 KBScreenshots, logos, text, transparency (lossless)
JPEG, quality 95photo_q95.jpg292.6 KBPhotos where detail matters
JPEG, quality 75photo_q75.jpg84.2 KBWeb photos (Pillow’s default quality)
WebP, quality 80photo.webp120.6 KBModern websites, transparency supported
BMPphoto.bmp1582.1 KBUncompressed, rarely worth it
GIFphoto.gif394.1 KBSimple 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:

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.