Short answer

Make a canvas with Image.new("RGB", (total_width, max_height)), then canvas.paste(img, (x, y)) each image, adding the previous widths to x. Resize the images to a common height (for a row) or width (for a column) first if you don't want gaps.

Two images side by side

The images here have different shapes, so we scale both to 600 px high first. Each width follows from its own aspect ratio:

side_by_side.py
from PIL import Image

a = Image.open("photo.jpg")       # 1600 x 1067, landscape
b = Image.open("portrait.jpg")    # 800 x 1200, portrait

# Scale both to the same height, keeping each one's aspect ratio
h = 600
a = a.resize((round(a.width * h / a.height), h), Image.LANCZOS)
b = b.resize((round(b.width * h / b.height), h), Image.LANCZOS)

canvas = Image.new("RGB", (a.width + b.width, h))
canvas.paste(a, (0, 0))
canvas.paste(b, (a.width, 0))     # x offset = width of everything to the left
canvas.save("side_by_side.jpg", quality=90)
print(a.size, b.size, "->", canvas.size)
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
(900, 600) (400, 600) -> (1300, 600)
A landscape sunset photo of a launch pad next to a portrait photo of the rocket, joined at equal height
Real output: both photos scaled to 600 px high and joined.

To stack vertically, swap the roles: scale to a common width, make the canvas (width, a.height + b.height) and paste the second image at (0, a.height).

Any number of images, any sizes

When you would rather not resize, put the images on a background and align them. This function handles rows and columns, gaps, alignment and transparent PNGs:

concat.py
from PIL import Image


def concat(images, direction="horizontal", gap=0, bg="white", align="center"):
    """Join images in a row or column. Images may differ in size; smaller ones are aligned."""
    horizontal = direction == "horizontal"
    along = [im.width if horizontal else im.height for im in images]
    across = [im.height if horizontal else im.width for im in images]
    length, depth = sum(along) + gap * (len(images) - 1), max(across)
    canvas = Image.new("RGB", (length, depth) if horizontal else (depth, length), bg)
    pos = 0
    for im, a, c in zip(images, along, across):
        offset = {"start": 0, "center": (depth - c) // 2, "end": depth - c}[align]
        xy = (pos, offset) if horizontal else (offset, pos)
        canvas.paste(im, xy, im if im.mode == "RGBA" else None)   # keep transparency
        pos += a + gap
    return canvas


photos = [Image.open(p) for p in ["photo.jpg", "portrait.jpg", "logo.png"]]
for im in photos:
    im.thumbnail((400, 400))
row = concat(photos, gap=20, bg="#e2e8f0")
col = concat(photos, "vertical", gap=20, bg="#e2e8f0", align="start")
row.save("row.png")
print("row:", row.size, " column:", col.size)
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
row: (1107, 400)  column: (400, 947)
Three images in a row on a light grey background, vertically centred, with 20 pixel gaps
Real output of concat(photos, gap=20): different heights, centred.

The third argument to paste() is a mask. Passing the RGBA image itself as its own mask keeps the logo's rounded corners transparent instead of black. More on that in pasting transparent images.

A grid or contact sheet

For a grid, compute each cell's position from its index: column i % cols, row i // cols. ImageOps.pad() fits each image inside its cell without cropping or distortion:

contact_sheet.py
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageOps

files = sorted(Path("photos").iterdir())
cols, cell, pad, label_h = 2, (360, 240), 16, 26
rows = -(-len(files) // cols)                       # ceiling division
font = ImageFont.truetype("DejaVuSans.ttf", 15)

sheet = Image.new("RGB", (cols * (cell[0] + pad) + pad, rows * (cell[1] + label_h + pad) + pad), "white")
draw = ImageDraw.Draw(sheet)
for i, path in enumerate(files):
    with Image.open(path) as im:
        thumb = ImageOps.pad(im.convert("RGB"), cell, color="#f1f5f9")   # fit inside the cell, letterboxed
    x = pad + (i % cols) * (cell[0] + pad)
    y = pad + (i // cols) * (cell[1] + label_h + pad)
    sheet.paste(thumb, (x, y))
    draw.text((x, y + cell[1] + 5), f"{path.name}  {im.width}x{im.height}", font=font, fill="#334155")
sheet.save("contact_sheet.jpg", quality=90)
print(len(files), "images ->", sheet.size)
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
4 images -> (768, 580)
A 2 by 2 contact sheet of four photos, each letterboxed in a grey cell with its file name and size underneath
Real output: four images of different shapes and sizes, letterboxed into equal cells.

Use ImageOps.fit() instead of pad() if you prefer cells filled edge to edge, cropping whatever does not fit.

TypeError: 'float' object cannot be interpreted as an integer

Positions and sizes must be integers. Dividing with / always produces a float, even when the result is whole:

centre.py
from PIL import Image

a = Image.open("photo.jpg")
canvas = Image.new("RGB", (a.width * 2, a.height))
canvas.paste(a, (a.width / 2, 0))        # / gives a float
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
Traceback (most recent call last):
  ...
TypeError: 'float' object cannot be interpreted as an integer

Use // (integer division) or wrap the value in round() or int(). The same applies to the size you pass to Image.new() and resize().

With NumPy

np.hstack and np.vstack work too, but only on arrays whose other dimensions match exactly, and they cannot add gaps or alignment:

numpy_stack.py
import numpy as np
from PIL import Image

a = np.asarray(Image.open("photo.jpg"))
b = np.asarray(Image.open("portrait.jpg"))
try:
    np.hstack([a, b])
except ValueError as e:
    print("ValueError:", e)

b = np.asarray(Image.open("portrait.jpg").resize((1600, 1067)))   # same shape, but now distorted
print(np.hstack([a, b]).shape, np.vstack([a, b]).shape)
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
ValueError: all the input array dimensions except for the concatenation axis must match exactly, but along dimension 0, the array at index 0 has size 1067 and the array at index 1 has size 1200
(1067, 3200, 3) (2134, 1600, 3)

Convert back with Image.fromarray(). See Pillow and NumPy.

Overlapping instead of joining: blend and fade

To merge two images on top of each other, use Image.blend() for a uniform mix, or Image.composite() with a mask to decide pixel by pixel. Both need images of the same size and mode:

blend.py
from PIL import Image

a = Image.open("photo.jpg").resize((800, 534))
b = Image.open("photos/img_3.jpg").resize((800, 534))
Image.blend(a, b, alpha=0.5).save("blend.jpg")                   # 50/50 mix of every pixel

mask = Image.linear_gradient("L").rotate(90).resize(a.size)       # black on the left, white on the right
Image.composite(a, b, mask).save("fade.jpg")                     # a where the mask is white, b where black
print("done")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
done
Two launch pad photos fading into each other from left to right
Real output: Image.composite() with a horizontal gradient mask.

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.