Short answer

buf = io.BytesIO(); img.save(buf, format="PNG"); data = buf.getvalue(). For base64: base64.b64encode(data).decode(), and for a data URI prefix it with data:image/png;base64,.

Image to bytes

to_bytes.py
import io
from PIL import Image

img = Image.open("photo.jpg").resize((800, 534))

buf = io.BytesIO()
img.save(buf, format="PNG")           # with a buffer there is no file name, so give the format
png_bytes = buf.getvalue()
print(len(png_bytes), "bytes, starts with", png_bytes[:8])
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
436256 bytes, starts with b'\x89PNG\r\n\x1a\n'

Those first bytes are the PNG signature, proof that you have a real PNG file in memory. Use format="JPEG" or "WEBP" with a quality for smaller results (JPEG needs an RGB or L image; see cannot write mode as JPEG).

tobytes() is not what you want (usually)

img.tobytes() returns the raw, uncompressed pixel values with no header. It is much bigger, and nothing else can open it as an image:

tobytes_vs_save.py
import io
from PIL import Image

img = Image.open("photo.jpg").resize((800, 534))
raw = img.tobytes()                   # raw pixel values, no file format
buf = io.BytesIO(); img.save(buf, "JPEG", quality=85)

print("tobytes():   ", len(raw), "bytes =", img.width, "x", img.height, "x", len(img.getbands()))
print("saved JPEG:  ", buf.tell(), "bytes")

try:
    Image.open(io.BytesIO(raw))       # raw pixels are not a file Pillow can open
except Exception as e:
    print(type(e).__name__ + ":", e)
same = Image.frombytes(img.mode, img.size, raw)   # you need mode and size to rebuild it
print("frombytes():", same.size, same.mode)
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
tobytes():    1281600 bytes = 800 x 534 x 3
saved JPEG:   52193 bytes
UnidentifiedImageError: cannot identify image file <_io.BytesIO object at 0x7fc90ef3ade0>
frombytes(): (800, 534) RGB

Use tobytes()/frombytes() only to pass pixels between libraries in the same process, where both sides know the mode and size. For storing or sending images, save to a format.

Base64 and data URIs

to_base64.py
import base64
import io
from PIL import Image

img = Image.open("logo.png").resize((120, 72))
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")       # str, ready for JSON
data_uri = f"data:image/png;base64,{b64}"
print(len(b64), "chars;", data_uri[:40] + "...")
open("inline.html", "w").write(f'<img src="{data_uri}" alt="logo">')
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
3968 chars; data:image/png;base64,iVBORw0KGgoAAAANSU...

Base64 makes data about a third bigger. We measured it for three formats:

base64_overhead.py
import base64
import io
from PIL import Image

img = Image.open("photo.jpg").resize((800, 534))
for fmt, opts in [("PNG", {}), ("JPEG", {"quality": 85}), ("WEBP", {"quality": 80})]:
    buf = io.BytesIO()
    img.save(buf, fmt, **opts)
    b64 = base64.b64encode(buf.getvalue())
    print(f"{fmt:<5} {buf.tell() / 1024:7.1f} KB binary -> {len(b64) / 1024:7.1f} KB base64 (+{len(b64) / buf.tell() - 1:.0%})")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
PNG     426.0 KB binary ->   568.0 KB base64 (+33%)
JPEG     51.0 KB binary ->    68.0 KB base64 (+33%)
WEBP     27.7 KB binary ->    36.9 KB base64 (+33%)

So embed small images (icons, thumbnails) as data URIs, and serve larger ones as files.

Return an image from a web app without saving a file

These framework snippets are standard patterns (they need a running server, so we did not execute them here):

Flask

flask_app.py
import io
from flask import Flask, send_file
from PIL import Image, ImageDraw

app = Flask(__name__)


@app.get("/badge/<text>.png")
def badge(text):
    img = Image.new("RGB", (240, 60), "#2563eb")
    ImageDraw.Draw(img).text((120, 30), text, fill="white", anchor="mm", font_size=28)
    buf = io.BytesIO()
    img.save(buf, "PNG")
    buf.seek(0)                        # send_file reads from the current position
    return send_file(buf, mimetype="image/png")

The seek(0) matters for Flask: send_file() reads from the current position, which is the end of the buffer after saving. (It is Image.open() that rewinds automatically, not other libraries.)

FastAPI

fastapi_app.py
import io
from fastapi import FastAPI
from fastapi.responses import Response
from PIL import Image

app = FastAPI()


@app.get("/thumb.webp")
def thumb():
    img = Image.open("photo.jpg")
    img.thumbnail((400, 400))
    buf = io.BytesIO()
    img.save(buf, "WEBP", quality=80)
    return Response(content=buf.getvalue(), media_type="image/webp")

Django

django_models.py
import io
from django.core.files.base import ContentFile
from PIL import Image

def make_thumbnail(instance):
    img = Image.open(instance.photo)          # an ImageField / uploaded file
    img.thumbnail((400, 400))
    buf = io.BytesIO()
    img.convert("RGB").save(buf, "JPEG", quality=85)
    instance.thumbnail.save("thumb.jpg", ContentFile(buf.getvalue()), save=False)

If Django says it "cannot use ImageField because Pillow is not installed", see No module named 'PIL'; it is the same environment problem.

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.