Creating a simple image processing API with Python and Flask
A small image API helps you learn how uploads, decoders and HTTP responses fit together. Here we build two endpoints with Flask and Pillow: one stretches an image to a requested size, and the other converts it to PNG or JPEG. Both use the same validation and encoding path.
This is a loopback development service, not an authenticated public upload service. File-size, pixel and concurrency limits reduce resource use; they do not make a native decoder a sandbox.
Introduction to image processing APIs
The request contains one multipart file plus operation parameters. The server checks the decoded format and dimensions, performs the operation and returns image bytes. Filenames and browser-supplied MIME types are not evidence that an upload is safe.
Setting up the development environment
Use Python 3.12 or newer, Bash, and cURL on Linux. Check python3 --version and confirm that
curl --help all lists --fail-with-body before creating files. In a new directory, create
requirements.txt:
Flask==3.1.3
Pillow==12.3.0
Flask-Limiter==4.1.1
gunicorn==26.2.0
Then install into a virtual environment:
python3 -m venv .venv &&
source .venv/bin/activate &&
python -m pip install -r requirements.txt
If environment creation or installation fails, fix that error and repeat the setup block in the
same directory; keep requirements.txt and your source files. The commands stop at the failed step.
The virtual environment keeps this example’s packages separate from an enclosing Python project.
Creating a basic Flask application
Put this complete application in app.py. There is one startup block, after all routes and handlers:
import io
import re
import threading
import warnings
from flask import Flask, request, send_file
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from PIL import Image, ImageOps, UnidentifiedImageError
from werkzeug.exceptions import BadRequest, HTTPException
MAX_BYTES = 8 * 1024 * 1024
MAX_PIXELS = 12_000_000
MAX_SIDE = 2048
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
warnings.simplefilter("error", Image.DecompressionBombWarning)
app = Flask(__name__)
# Leave room for Werkzeug's 64 KiB multipart parser chunks, including framing.
app.config.update(MAX_CONTENT_LENGTH=MAX_BYTES + 64 * 1024,
MAX_FORM_MEMORY_SIZE=128 * 1024, MAX_FORM_PARTS=8)
limiter = Limiter(get_remote_address, app=app,
default_limits=["60 per minute"], storage_uri="memory://")
slots = threading.BoundedSemaphore(2)
def dimension(name, default):
values = request.form.getlist(name)
text = values[0] if len(values) == 1 else default
if len(values) > 1 or re.fullmatch(r"[0-9]{1,4}", text) is None:
raise BadRequest()
value = int(text)
if not 1 <= value <= MAX_SIDE:
raise BadRequest()
return value
def load_image():
files = request.files.getlist("file")
if len(files) != 1 or len(request.files) != 1:
raise BadRequest()
data = files[0].read(MAX_BYTES + 1)
if not data or len(data) > MAX_BYTES:
raise BadRequest()
with Image.open(io.BytesIO(data), formats=("JPEG", "PNG")) as source:
# Pillow exposes some 16-bit PNGs as RGB/RGBA, so mode alone cannot enforce this limit.
if source.format == "PNG" and (data[12:16] != b"IHDR" or data[24] > 8):
raise BadRequest()
if source.width * source.height > MAX_PIXELS or getattr(source, "n_frames", 1) != 1:
raise BadRequest()
source.load()
oriented = ImageOps.exif_transpose(source)
try:
return oriented.convert("RGBA")
finally:
oriented.close()
def encode_image(image, target):
image.info.clear()
output = io.BytesIO()
if target == "JPEG":
background = Image.new("RGB", image.size, "white")
try:
background.paste(image, mask=image.getchannel("A"))
background.save(output, format="JPEG", quality=85)
finally:
background.close()
else:
image.save(output, format="PNG")
output.seek(0)
response = send_file(output, mimetype=Image.MIME[target],
download_name="result." + ("jpg" if target == "JPEG" else "png"))
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
def process_image(resize):
if not slots.acquire(blocking=False):
return {"error": "Image processor is busy."}, 503
try:
allowed = {"width", "height"} if resize else {"format"}
if any(name not in allowed for name in request.form):
raise BadRequest()
formats = request.form.getlist("format")
target = formats[0].upper() if formats else "PNG"
if len(formats) > 1 or target not in ("PNG", "JPEG"):
raise BadRequest()
width = dimension("width", "100") if resize else None
height = dimension("height", "100") if resize else None
with load_image() as image:
if resize:
with image.resize((width, height), Image.Resampling.LANCZOS) as result:
return encode_image(result, "PNG")
return encode_image(image, target)
except (BadRequest, UnidentifiedImageError, OSError, ValueError,
Image.DecompressionBombError, Image.DecompressionBombWarning):
return {"error": "Provide one valid single-frame JPEG or PNG and supported parameters."}, 400
finally:
slots.release()
@app.post("/resize")
def resize_image():
return process_image(resize=True)
@app.post("/convert")
def convert_image():
return process_image(resize=False)
@app.errorhandler(HTTPException)
def http_error(error):
messages = {413: "Upload is too large.", 429: "Too many requests."}
return {"error": messages.get(error.code, "Request could not be processed.")}, error.code
@app.errorhandler(Exception)
def unexpected_error(error):
# Avoid logging file content, headers or untrusted decoder messages.
app.logger.error("Unexpected image processing failure: %s", type(error).__name__)
return {"error": "Image processing failed."}, 500
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=False)
Integrating Pillow for image processing
load_image() opens only JPEG and PNG decoders. This example accepts PNGs with up to eight bits per
channel and rejects 16-bit PNGs rather than silently changing their numeric range. It rejects animated
PNGs and checks decoded pixel count before loading the image. It applies EXIF orientation and creates
RGBA pixels for the shared processing path. The output deliberately drops source metadata, including
embedded GPS data. It does not apply ICC color profiles; convert color-managed inputs to sRGB first.
Pillow’s image modes documentation explains the eight-bit RGBA representation and its limits.
The 8 MiB file limit is separate from the multipart request limit. Neither compressed file size nor dimensions alone bound every decoder's CPU and memory use. Keep Pillow current and isolate public processing workers with operating-system resource limits.
Implementing image resizing endpoint
Start the application with python app.py. Leave it running and open another terminal in the same
directory for cURL. Place a local JPEG named photo.jpg there, then resize it:
curl --fail-with-body -F "file=@photo.jpg" -F "width=300" -F "height=200" \
http://127.0.0.1:5000/resize --output resized.png
On success, this command returns a 300 by 200 PNG. This operation stretches the image; it does not preserve aspect ratio. Each output side must be between 1 and 2048 pixels. For aspect-preserving thumbnails, choose a fit or crop policy explicitly instead of silently changing the requested geometry.
Open resized.png to check its contents. cURL replaces the named output file on a successful rerun;
with --fail-with-body, an HTTP error can write JSON there instead, so also check cURL’s exit status.
Adding image format conversion endpoint
/convert accepts PNG or JPEG, case-insensitively. PNG retains transparency. JPEG cannot
represent transparency, so the example composites transparent pixels onto white. Place an eight-bit
PNG named transparent.png in the same directory, then convert it:
curl --fail-with-body -F "file=@transparent.png" -F "format=JPEG" \
http://127.0.0.1:5000/convert --output converted.jpg
Open converted.jpg: transparent areas should be white, and partially transparent pixels should
blend with white. Both endpoints return images directly; the application does not save uploads or
results on the server. Stop the server with Ctrl+C when you finish.
Testing the API with sample requests
Put these tests in test_app.py and run python -m unittest -v. Flask's test client exercises the
actual request parser and Pillow encoder without starting a server:
import io
import random
import unittest
from PIL import Image
from app import app, limiter
class ImageApiTest(unittest.TestCase):
def setUp(self):
app.config.update(TESTING=True)
limiter.enabled = False
self.client = app.test_client()
def upload(self, path, input_format="PNG", **fields):
image = io.BytesIO()
mode = "RGB" if input_format == "JPEG" else "RGBA"
with Image.new(mode, (80, 40), (200, 40, 80) if mode == "RGB" else (200, 40, 80, 128)) as source:
source.paste((20, 60, 220) if mode == "RGB" else (20, 60, 220, 255), (52, 0, 80, 40))
if mode == "RGBA":
source.paste((0, 150, 250, 0), (0, 0, 24, 40))
source.save(image, input_format)
image.seek(0)
return self.client.post(path, data={"file": (image, "photo"), **fields})
def assert_pixel(self, image, position, expected, tolerance=5):
for actual, wanted in zip(image.getpixel(position), expected):
self.assertLessEqual(abs(actual - wanted), tolerance)
def test_resize(self):
response = self.upload("/resize", width="12", height="8")
self.assertEqual(response.status_code, 200)
with Image.open(io.BytesIO(response.data)) as image:
self.assertEqual((image.format, image.size), ("PNG", (12, 8)))
self.assertEqual(image.getpixel((0, 0))[3], 0)
self.assert_pixel(image, (11, 7), (20, 60, 220, 255))
def test_alpha_to_jpeg(self):
response = self.upload("/convert", format="JPEG")
self.assertEqual(response.status_code, 200)
with Image.open(io.BytesIO(response.data)) as image:
self.assertEqual(image.format, "JPEG")
self.assert_pixel(image, (8, 20), (255, 255, 255))
self.assert_pixel(image, (38, 20), (227, 147, 167))
self.assert_pixel(image, (70, 20), (20, 60, 220))
def test_jpeg_input(self):
response = self.upload("/resize", input_format="JPEG", width="10", height="5")
self.assertEqual(response.status_code, 200)
with Image.open(io.BytesIO(response.data)) as image:
self.assertEqual((image.format, image.size), ("PNG", (10, 5)))
self.assert_pixel(image, (0, 0), (200, 40, 80, 255))
self.assert_pixel(image, (9, 4), (20, 60, 220, 255))
def test_rejects_16_bit_png(self):
data = io.BytesIO()
with Image.new("I;16", (40, 20), 32768) as image:
image.save(data, "PNG")
data.seek(0)
response = self.client.post("/convert", data={"file": (data, "gray.png")})
self.assertEqual(response.status_code, 400)
def test_multipart_file_larger_than_a_parser_chunk(self):
data = io.BytesIO()
pixels = random.Random(0).randbytes(512 * 512 * 3)
with Image.frombytes("RGB", (512, 512), pixels) as image:
image.save(data, "JPEG", quality=90)
self.assertGreater(data.tell(), 64 * 1024)
data.seek(0)
response = self.client.post("/resize", data={"file": (data, "photo.jpg")})
self.assertEqual(response.status_code, 200)
with Image.open(io.BytesIO(response.data)) as image:
self.assertEqual(image.size, (100, 100))
def test_invalid_dimensions(self):
for width in ("0", "-1", "2049", "NaN", "12px"):
with self.subTest(width=width):
self.assertEqual(self.upload("/resize", width=width).status_code, 400)
def test_invalid_upload(self):
response = self.client.post("/resize", data={"file": (io.BytesIO(b"invalid"), "x.png")})
self.assertEqual(response.status_code, 400)
if __name__ == "__main__":
unittest.main()
Error handling and input validation
Expected invalid inputs receive a generic 400 response; an oversized HTTP request receives 413. Each endpoint allows 60 requests per minute per client IP. Rate limits return 429, and a request that reaches the application while both processing slots are occupied returns 503. An HTTP server can still queue connections before they reach the application. Unexpected failures return a sanitized 500 response, never a decoder exception or stack trace. A file over 8 MiB within the HTTP request limit receives 400; Flask’s resource-use limits can produce 413 earlier for the request, form fields, or part count.
Run the local API with Gunicorn
On Linux, a single Gunicorn worker keeps this demo's in-memory limits in one process:
gunicorn --bind 127.0.0.1:5000 --workers 1 --threads 2 --timeout 30 \
--no-control-socket app:app
Keep this listener on loopback. Configure an authenticated HTTPS entry point and worker isolation separately before accepting remote uploads. The optional management socket is disabled so this demo does not share Gunicorn’s default socket path with another local instance. Ctrl+C stops the server; worker shutdown can take up to the default 30-second grace period.
Before adding workers or instances, configure a shared rate-limit store using the Flask-Limiter storage documentation. Memory-backed counters reset on restart and are not shared across processes. A server timeout is not a per-operation cancellation mechanism for Pillow.
Conclusion and next steps
Extend the tests for your required formats and geometry, then measure resource use with representative files. For a managed workflow, explore Transloadit's image processing service instead of running your own decoder fleet.
