Guide
Formatting And Charting Excel Reports With PythonDeep dive

Resize and Position Images in Excel with openpyxl

Place a logo or chart image exactly where you want it — pixel sizing, aspect ratio, cell and offset anchors, and why an image moves when a column is resized.

A logo in the wrong place makes a generated report look generated. openpyxl inserts images in two lines, and then the questions start: how big, measured in what, anchored to what, and why did it jump three columns when the widths changed? The answers come down to pixels versus EMU and to which anchor type you choose. This guide covers sizing without distortion, precise placement, and the round-trip behaviour that quietly drops images from a template. It extends Inserting Images and Logos into Excel.

One-cell and two-cell anchors behave differently when the layout changes A one-cell anchor pins the image's top-left corner to a cell and gives it a fixed extent, so widening a column moves the image but never changes its size — which is what a logo needs. A two-cell anchor pins opposite corners to two different cells, so the image stretches or shrinks as those cells move, which suits a chart image meant to fill a region but distorts a logo. OneCellAnchor logo one corner pinned, fixed extent widening a column moves it but never resizes it right for a logo TwoCellAnchor stretched between two cells both corners pinned widening a column stretches it aspect ratio is not preserved right for filling a region

Prerequisites

Bash
pip install openpyxl pillow

Pillow is not optional here — openpyxl uses it to read the image's dimensions, and without it Image raises on anything but the simplest case.

Python
from openpyxl import Workbook
from openpyxl.drawing.image import Image

wb = Workbook()
ws = wb.active
ws["A1"] = "Regional Revenue Report"

img = Image("logo.png")
ws.add_image(img, "E1")
wb.save("report.xlsx")

That inserts the image at its natural size, top-left corner in cell E1. Everything else is refinement.

Step 1 — Size without distortion

width and height are in pixels at 96 dpi. Setting both independently stretches the image, so compute one from the other:

Python
from openpyxl.drawing.image import Image
from PIL import Image as PILImage

def sized_image(path, width=None, height=None):
    """Load an image scaled to a target width or height, preserving proportions."""
    if width is None and height is None:
        return Image(path)

    with PILImage.open(path) as source:
        native_w, native_h = source.size

    ratio = native_h / native_w
    if width is not None:
        target_w, target_h = width, round(width * ratio)
    else:
        target_h, target_w = height, round(height / ratio)

    img = Image(path)
    img.width, img.height = target_w, target_h
    return img

logo = sized_image("logo.png", width=180)
ws.add_image(logo, "E1")

Passing one dimension and deriving the other is the whole discipline. A logo squashed by ten per cent is the kind of thing nobody can name but everybody notices.

Make the row tall enough, or the image overlaps the data below it. Row height is in points, not pixels, and the conversion is 0.75 points per pixel:

Python
PX_TO_POINTS = 0.75

def fit_row_to_image(ws, row, image, padding_px=8):
    """Set a row's height so an image sits inside it."""
    ws.row_dimensions[row].height = (image.height + padding_px) * PX_TO_POINTS

Three different units in play — pixels for images, points for row heights, characters for column widths — is genuinely confusing, and it is why placement often needs one round of trial and error.

Step 2 — Anchor precisely

A plain cell reference pins the top-left corner to that cell's top-left corner. For finer control, build the anchor yourself. Offsets are in EMU, where 9,525 EMU is one pixel:

Python
from openpyxl.drawing.spreadsheet_drawing import (
    AnchorMarker, OneCellAnchor,
)
from openpyxl.drawing.xdr import XDRPositiveSize2D
from openpyxl.utils.units import pixels_to_EMU

EMU_PER_PIXEL = 9525

def anchored_image(path, col, row, width=None, height=None,
                   col_offset_px=0, row_offset_px=0):
    """An image at an exact offset inside a cell, with a fixed size."""
    img = sized_image(path, width=width, height=height)

    marker = AnchorMarker(
        col=col, row=row,                       # zero-based
        colOff=pixels_to_EMU(col_offset_px),
        rowOff=pixels_to_EMU(row_offset_px),
    )
    img.anchor = OneCellAnchor(
        _from=marker,
        ext=XDRPositiveSize2D(
            pixels_to_EMU(img.width), pixels_to_EMU(img.height)
        ),
    )
    return img

# 12 pixels in and 6 down from the top-left of E2 (col 4, row 1 zero-based).
ws.add_image(anchored_image("logo.png", col=4, row=1, width=180,
                            col_offset_px=12, row_offset_px=6))

Note the zero-based col and row in AnchorMarker, against openpyxl's one-based cell addressing everywhere else. Column E is col=4, row 2 is row=1. Getting this wrong shifts the image one cell up and left, which looks like an off-by-one because it is one.

OneCellAnchor with an explicit ext is what keeps the size fixed. The image still moves if the anchoring cell moves, but it never stretches — which is the behaviour a logo wants.

For an image that should fill a region and resize with it, use TwoCellAnchor instead:

Python
from openpyxl.drawing.spreadsheet_drawing import AnchorMarker, TwoCellAnchor

def stretched_image(path, from_col, from_row, to_col, to_row):
    """An image stretched between two cells — resizes with the layout."""
    img = Image(path)
    img.anchor = TwoCellAnchor(
        editAs="twoCell",
        _from=AnchorMarker(col=from_col, row=from_row),
        to=AnchorMarker(col=to_col, row=to_row),
    )
    return img

# Fill the block from E10 to K24.
ws.add_image(stretched_image("chart.png", 4, 9, 10, 23))

That is right for a rendered chart image meant to occupy a panel, and wrong for a logo, because the aspect ratio follows the cells rather than the image.

Step 3 — Lay out several images

A report footer with several logos, or a gallery of rendered charts, needs positions computed rather than typed.

Computing positions instead of typing cell references Four images placed across a sheet. A starting column and row fix the first position, a stride in columns sets the spacing, and each image's index multiplies the stride. Adding a fifth image needs no new cell reference, and changing the spacing is one number rather than four edits. The same arithmetic drives a grid by taking the quotient and remainder of the index against a column count. start column 1, stride 4 columns image 0 col 1 image 1 col 5 image 2 col 9 image 3 col 13 col = start + index × stride — a fifth image needs no new reference
Python
from openpyxl import Workbook

def place_row(ws, paths, start_col=1, start_row=1, stride=4, width=160,
              gap_px=8):
    """Place images left to right, evenly spaced."""
    placed = []
    for index, path in enumerate(paths):
        img = anchored_image(
            path,
            col=start_col + index * stride,
            row=start_row,
            width=width,
            col_offset_px=gap_px,
            row_offset_px=gap_px,
        )
        ws.add_image(img)
        placed.append(img)

    if placed:
        tallest = max(i.height for i in placed)
        ws.row_dimensions[start_row + 1].height = (tallest + 2 * gap_px) * 0.75
    return placed

def place_grid(ws, paths, columns=2, start_col=1, start_row=1,
               col_stride=6, row_stride=16, width=280):
    """Place images in a grid — useful for a page of rendered charts."""
    for index, path in enumerate(paths):
        down, across = divmod(index, columns)
        ws.add_image(anchored_image(
            path,
            col=start_col + across * col_stride,
            row=start_row + down * row_stride,
            width=width,
        ))

Computing the positions means adding a fifth chart is a list entry rather than four new cell references, and changing the spacing is one number. That composes well with the matplotlib workflow in embedding a matplotlib chart in an Excel report.

Step 4 — Remember that images do not survive a round trip

Three different units for one placement Placing an image touches three unit systems. Image width and height are in pixels at ninety-six dots per inch. Row height is in points, at three quarters of a pixel, so a sixty-pixel image needs a forty-five point row. Anchor offsets are in EMU, English Metric Units, at nine thousand five hundred and twenty-five per pixel. Mixing them up is why placement usually takes one round of trial and error. image size pixels img.width = 180 at 96 dpi derive one from the other row height points height = px × 0.75 60 px → 45 points too short and it overlaps anchor offsets EMU 9,525 per pixel pixels_to_EMU(12) and the index is zero-based

This is the behaviour that costs the most time. openpyxl drops images that were already in a workbook when it loads it — they are not parsed into the object model, so they are not written back:

Python
from openpyxl import load_workbook

wb = load_workbook("template_with_logo.xlsx")
print(len(wb.active._images))     # 0 — the logo is already gone
wb.save("output.xlsx")            # output has no logo

There is no flag to change it. The practical consequences:

  • Do not rely on a template's logo. Re-add every image your output needs, from files your script controls.
  • Keep the image assets with the code, so a report can always be rebuilt identically.
  • Use LibreOffice for a true copy when a template's images genuinely must survive untouched — soffice --headless --convert-to xlsx preserves them, as in converting .xls to .xlsx.

A helper that always re-applies the branding makes this a non-issue:

Python
from pathlib import Path

BRANDING = Path("assets")

def apply_branding(ws, logo="logo.png", col=0, row=0, width=180):
    """Re-add the logo. Call on every generated workbook, always."""
    path = BRANDING / logo
    if not path.exists():
        raise FileNotFoundError(f"branding asset missing: {path}")
    ws.add_image(anchored_image(str(path), col=col, row=row, width=width,
                                col_offset_px=8, row_offset_px=6))
    ws.row_dimensions[row + 1].height = (width * 0.35 + 12) * 0.75

Common pitfalls and fixes

SymptomCauseFix
Logo looks stretchedwidth and height set independentlyDerive one from the source aspect ratio.
Image overlaps the data belowRow too shortSet the row height, remembering points ≠ pixels.
Image one cell up and leftAnchorMarker is zero-basedColumn E is col=4, row 2 is row=1.
Image resizes when a column widensTwoCellAnchor usedUse OneCellAnchor with an explicit ext.
Template logo missing from the outputopenpyxl drops loaded imagesRe-add images in the script.
ImportError on ImagePillow not installedpip install pillow.
Offsets have no effectOffsets given in pixelsConvert with pixels_to_EMU.
Image not in the printed outputFalls outside the print areaSet ws.print_area to include it.

Performance and scale notes

Images are embedded, not referenced, so each one adds its full byte size to the workbook. A 2 MB PNG logo on twelve sheets is 24 MB of report — and the same logo at 40 KB is 480 KB, which nobody notices.

Three habits keep report files small. Resize the source, not just the display. Setting img.width scales the rendering while embedding the original bytes, so a 3,000-pixel-wide logo displayed at 180 pixels still costs its full size. Downscale the file once:

Python
from PIL import Image as PILImage

def prepare_asset(src, dest, max_width=400):
    """Downscale a source image once so every report embeds a small file."""
    with PILImage.open(src) as image:
        if image.width > max_width:
            ratio = max_width / image.width
            image = image.resize((max_width, round(image.height * ratio)),
                                 PILImage.LANCZOS)
        image.save(dest, optimize=True)
    return dest

Choose the format deliberately. PNG for logos and anything with flat colour or transparency; JPEG for photographs, where it is several times smaller. A screenshot saved as JPEG looks blurry around text, and a photograph saved as PNG is needlessly large.

Insert each distinct image once per workbook where you can. openpyxl embeds a separate copy per add_image call, so a logo repeated on twelve sheets is stored twelve times. Where the file size matters more than the repetition, put the logo on the first sheet only and rely on a header row elsewhere.

For very large reports, note that images cannot be added in write_only mode — that mode streams rows and never holds the drawing collection. Stream the data first, then re-open the workbook normally to add images, which is cheap because the second pass touches only the drawing parts. The streaming approach itself is covered in writing large DataFrames with write-only mode.

Conclusion

Placing an image well comes down to three things. Size it from one dimension and derive the other from the source aspect ratio, so it is never stretched. Anchor it with a OneCellAnchor and an explicit extent when it is a logo that must keep its proportions, or a TwoCellAnchor when it is a chart image meant to fill a region. And remember the units differ at every level — pixels for images, points for row heights, EMU for offsets at 9,525 to the pixel, and zero-based indexes in AnchorMarker where the rest of openpyxl is one-based. Then re-add every image on every run, because openpyxl will not carry a template's images through.

Frequently asked questions

What units are the width and height in? Pixels, at 96 dots per inch. openpyxl converts them to the EMU units the file format uses, so you set a sensible pixel size and it handles the rest.

Why is my logo stretched? You set width and height independently. Compute one from the other using the source image's aspect ratio, which Pillow reports, so the proportions are preserved.

Why did the image move when I widened a column? The default anchor ties the image to a cell, so it follows that cell as the layout changes. Use a OneCellAnchor with an explicit extent to keep the size fixed, or accept the movement as intended behaviour.

How do I place an image at an exact offset within a cell? Build an AnchorMarker with col, row, colOff and rowOff values. The offsets are in EMU, where 9,525 EMU is one pixel, so convert your pixel offset with pixels_to_EMU.

Do images survive an openpyxl round trip? No. openpyxl drops images that were already in a workbook when it loads it, so re-add every image your output needs rather than assuming the ones in a template will persist.