Guide
Formatting And Charting Excel Reports With PythonDeep dive

Inserting Images and Logos into Excel

Embed logos and images into Excel with openpyxl: floating drawings anchored to cells, pixel resizing, header bands, and why pandas can't keep them.

A branded report needs a logo in the top-left corner, and a .xlsx file can hold one — but not the way you might expect. An image in Excel is not a cell value. It is a floating drawing that sits on top of the grid, anchored to a cell but living in its own layer. That single fact explains every quirk covered here: images do not push cells aside, they do not appear in ws["A1"].value, and they vanish the moment a tool that does not understand drawings rewrites the file. This page is the practical guide to getting a logo in, sized right, and kept there. It is part of Formatting and Charting Excel Reports with Python.

Everything below is plain openpyxl plus Pillow, and every block builds its own tiny PNG inline, so you can paste and run without supplying an image of your own.

An Excel image is a floating drawing, not a cell value A logo embedded with openpyxl is anchored to cell A1 but sits in its own drawing layer above the worksheet grid, so the cell value stays empty and a pandas rewrite drops the image. A floating layer over the grid A B C D logo.png drawing layer anchored at A1 — it floats above cells, it is not in them What that means ws["A1"].value → None cells are not pushed aside needs Pillow to embed pandas rewrite drops it

Install openpyxl and Pillow

openpyxl writes the .xlsx drawing XML itself, but it leans on Pillow to read the image you hand it — measure its dimensions, validate the format, and convert anything that is not already a clean PNG. Without Pillow installed, openpyxl.drawing.image.Image("logo.png") raises ImportError the moment you construct it.

Bash
pip install openpyxl pillow

That is the whole toolchain. Neither library needs Excel installed, so these scripts run on a headless CI runner or a Linux server exactly as they run on your laptop.

Embed an image anchored to a cell

The core API is small: build an openpyxl.drawing.image.Image, then ws.add_image(img, "A1") to anchor its top-left corner at a cell. The image floats — anchoring to A1 pins where it starts, not which cells it occupies. It will happily overlap B1, C1, and the rows below if it is larger than that cell.

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

# Build a small placeholder logo so this runs end to end
PILImage.new("RGB", (160, 50), color="#4472C4").save("logo.png")

wb = Workbook()
ws = wb.active
ws.title = "Report"

logo = XLImage("logo.png")
ws.add_image(logo, "A1")          # top-left corner anchored to A1

wb.save("with_logo.xlsx")
print("Embedded logo.png anchored at A1")

The image bytes are copied into the workbook, so with_logo.xlsx is self-contained — email it and the logo travels along. You do not keep a reference to logo.png inside the file; openpyxl has already absorbed it (with one timing caveat covered below).

Resize a logo in pixels

A raw logo is rarely the right size for a report header. Set img.width and img.height in pixels before adding the image. These are display dimensions on the sheet, independent of the source file's real resolution, so you control exactly how big the logo renders.

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

PILImage.new("RGB", (400, 120), color="#4472C4").save("big_logo.png")

wb = Workbook()
ws = wb.active

logo = XLImage("big_logo.png")
logo.width = 200      # pixels on the sheet
logo.height = 60      # pixels on the sheet
ws.add_image(logo, "A1")

wb.save("resized_logo.xlsx")
print(f"Logo rendered at {logo.width}x{logo.height}px")

There is no "lock aspect ratio" flag — setting width and height independently can stretch the image. To keep proportions, read the source size with Pillow and scale by a single factor: scale = 200 / src_w; logo.width, logo.height = int(src_w * scale), int(src_h * scale). Add a Logo Image to an Excel Report with openpyxl walks through that aspect-safe sizing step by step.

Build a header band with a logo and title

Because the logo floats and never pushes cells, you reserve space for it yourself with row height and place the title text in a merged cell beside it. Tall first row, merged title across a few columns, logo anchored at A1 — that is the standard report header.

Python
from openpyxl import Workbook
from openpyxl.drawing.image import Image as XLImage
from openpyxl.styles import Font, Alignment
from PIL import Image as PILImage

PILImage.new("RGB", (150, 45), color="#4472C4").save("brand.png")

wb = Workbook()
ws = wb.active
ws.title = "Sales"

# Reserve a header band: one tall row for the logo to sit in
ws.row_dimensions[1].height = 48

# Merged title to the right of the logo
ws.merge_cells("B1:E1")
title = ws["B1"]
title.value = "Weekly Sales Report"
title.font = Font(bold=True, size=16, color="1F3864")
title.alignment = Alignment(vertical="center")

logo = XLImage("brand.png")
logo.width, logo.height = 150, 45
ws.add_image(logo, "A1")

# Data starts cleanly below the band
ws.append([])  # spacer row 2
ws.append(["Region", "Revenue"])
ws.append(["North", 25640])

wb.save("header_band.xlsx")
print("Built a header band: logo at A1, title merged across B1:E1")

The 48-pixel row height gives the 45-pixel logo room without overlapping the data, and the merged B1:E1 keeps the title from colliding with the logo's float. Widen column A to the logo's pixel width (roughly width / 7 in Excel character units) if you want the logo fully contained rather than spilling toward B.

Insert a logo from memory, no temp file

In a reporting pipeline the logo often arrives as raw bytes — fetched over HTTP, pulled from a database BLOB, or rendered on the fly with Pillow — and writing it to a temporary file just to hand openpyxl a path is wasteful. XLImage accepts any file-like object, so an in-memory BytesIO buffer works directly, no disk round-trip required.

Python
from io import BytesIO
from openpyxl import Workbook
from openpyxl.drawing.image import Image as XLImage
from PIL import Image as PILImage

# Render (or download) the logo straight into memory — nothing hits disk
buffer = BytesIO()
PILImage.new("RGB", (150, 45), color="#4472C4").save(buffer, format="PNG")
buffer.seek(0)                    # rewind so openpyxl reads from the start

wb = Workbook()
ws = wb.active

logo = XLImage(buffer)
logo.width, logo.height = 150, 45
ws.add_image(logo, "A1")

wb.save("in_memory_logo.xlsx")
print("Embedded a logo from a BytesIO buffer — no temp file")

Two rules keep this reliable. Pass format="PNG" when Pillow saves to a buffer — with no filename to inspect it cannot infer the format — and call buffer.seek(0) so openpyxl reads from byte zero rather than the buffer's end. Keep the buffer alive until after wb.save(), the same timing caveat that applies to on-disk files: openpyxl only reads the image bytes when the workbook is written.

Add a logo to a pandas report

pandas.to_excel writes data and nothing else — it has no API to embed an image, because it only knows about cell values. The pattern is therefore two steps: pandas writes the data, then you re-open the file with openpyxl and add the logo. This is the same write-then-decorate flow the rest of this track uses, and it builds on Using openpyxl for Excel File Manipulation.

Two-step order: pandas writes the data, then openpyxl adds the logo Step one, pandas.to_excel writes the table with startrow=3, leaving rows 1 to 3 as an empty band. Step two, load_workbook plus add_image drops the logo into that band and wb.save writes it. Reversing the order lets a pandas rewrite erase the logo. openpyxl must write last 1 · pandas writes the table rows 1–3 empty band · startrow=3 Region Revenue North 25,640 South 18,890 West 31,200 re-open load_workbook 2 · openpyxl adds the logo logo.png Region Revenue North 25,640 South 18,890 West 31,200 Reverse the order and pandas rebuilds the sheet from the DataFrame alone — the logo is erased. The openpyxl step must be the last write to the file.
Python
import pandas as pd
from openpyxl import load_workbook
from openpyxl.drawing.image import Image as XLImage
from PIL import Image as PILImage

PILImage.new("RGB", (140, 44), color="#4472C4").save("co_logo.png")

# 1. pandas writes the raw table, starting a few rows down to leave a band
df = pd.DataFrame({"Region": ["North", "South"], "Revenue": [25640, 18890]})
df.to_excel("pandas_report.xlsx", sheet_name="Sales",
            index=False, startrow=3)

# 2. re-open with openpyxl and drop the logo into the empty band
wb = load_workbook("pandas_report.xlsx")
ws = wb["Sales"]
ws.row_dimensions[1].height = 40

logo = XLImage("co_logo.png")
logo.width, logo.height = 140, 44
ws.add_image(logo, "A1")

wb.save("pandas_report.xlsx")
print("pandas wrote the data; openpyxl added the logo")

startrow=3 reserves rows 1-3 for the header band so the logo never overlaps the table. The crucial ordering rule: do the openpyxl step last.

Why pandas erases your images

If you embed a logo and later write to that same file with pandas.to_excel, the logo disappears. pandas does not read or preserve drawings — when it writes a sheet it regenerates the file's XML from the DataFrame alone, dropping the image, the native charts, and most styling along with it. The fix is sequencing.

SymptomCauseFix
Logo gone after a later pandas writeto_excel rebuilds the sheet from the DataFrame and discards drawingsMake the openpyxl image step the last write to the file
ImportError on Image(...)Pillow not installedpip install pillow
Logo overlaps the data tableImages float; they never push cells downReserve a band with row_dimensions[1].height and startrow
Image missing from saved fileSource PNG deleted before wb.save()Keep the file on disk until after the save call

The mental model: pandas owns data; openpyxl owns the decorated artifact. Once a workbook carries a logo, every subsequent edit must go through openpyxl, never back through pandas.

Images float above the grid

The single fact that explains every image question in Excel is that a picture is a drawing anchored to a cell rather than a value inside one. It does not affect row heights, it is not returned by any cell read, and it does not move when data below it grows:

A cell value compared with an anchored image A cell value lives in the grid, so it affects row height and is returned when the cell is read. An image is a floating drawing anchored to a cell position; it overlaps whatever is beneath it and is invisible to any cell read. a cell value lives in the grid affects row height read back with cell.value an anchored image floats above the grid changes no row height invisible to cell reads
Python
from openpyxl import load_workbook
from openpyxl.drawing.image import Image

wb = load_workbook("report.xlsx")
ws = wb["Summary"]

logo = Image("logo.png")
logo.width, logo.height = 160, 48          # pixels, set before anchoring
ws.add_image(logo, "A1")

ws.row_dimensions[1].height = 40           # make room manually — the image will not
ws.merge_cells("B1:F1")                    # a title area beside the logo
ws["B1"] = "Monthly report — March 2026"

wb.save("report_with_logo.xlsx")

Reserving space is always manual. Because the drawing floats, a logo dropped into A1 sits on top of whatever is in the first few rows, and the fix is to set the row height and, usually, to merge a title area beside it rather than to reposition the image.

Keep the image step last

Any pandas write to the sheet replaces it, and the drawing goes with it — so images belong in the same "decoration last" phase as filters and styling. The other ordering rule concerns file size: resize images before embedding rather than after, because openpyxl stores the file you hand it. A 2 MB photograph scaled down to 160 pixels wide is still 2 MB inside the workbook, and a report with a logo on every sheet quickly becomes an attachment nobody can email.

Python
from PIL import Image as PILImage

def prepare_logo(source, target="logo_small.png", width=160):
    img = PILImage.open(source)
    ratio = width / img.width
    img.resize((width, int(img.height * ratio))).save(target, optimize=True)
    return target

One resize at build time keeps every generated report small, and it costs a single dependency that most reporting environments already have.

Anchors decide what happens when rows move

An image is attached to the sheet at an anchor, and the anchor type decides how it behaves when rows and columns around it change. openpyxl's default is a one-cell anchor, which moves the image with its cell but never resizes it:

One-cell anchoring compared with a fixed position A one-cell anchor keeps the image attached to a cell, so inserting rows above moves it down with the content. An absolute anchor pins it to a position on the sheet, so it stays put while the data moves underneath. anchored to a cell moves when rows are inserted stays beside its content the usual choice absolute position stays where it was placed data can slide underneath for fixed banners
Python
from openpyxl import load_workbook
from openpyxl.drawing.image import Image

wb = load_workbook("report.xlsx")
ws = wb["Summary"]

logo = Image("logo_small.png")
logo.anchor = "A1"                       # attached to A1: moves if rows are inserted above
ws.add_image(logo)

wb.save("report_anchored.xlsx")

For a report header the cell anchor is nearly always right — the logo should stay with the title block if the layout shifts. The case for a fixed position is a watermark or a background element that must not follow the data, and that is rare enough to be worth a comment when it appears.

Reading images back is limited: openpyxl exposes ws._images so you can count and inspect them, but a round trip through pandas discards them entirely. Any job that edits a workbook containing logos therefore has to use openpyxl end to end, or re-add the images as a final step.

Images are the last step, always

Because a drawing is discarded whenever pandas rewrites its sheet, embedding images belongs at the very end of the build — after the data, after the formatting, immediately before the save. Treating it as part of the same finishing pass that sets widths and freezes panes is what stops a logo disappearing from next month's report without anyone changing the code that added it.

Keep the asset small and versioned

The image a report embeds should live in the repository next to the code, resized to the dimensions it will be displayed at, and referenced by a path the job controls. A logo pulled from a shared drive breaks the day someone tidies the folder; a full-resolution photograph embedded at 160 pixels wide adds megabytes to every report for no visible benefit. One prepared asset, checked in and reused across every sheet, keeps generated workbooks small and removes an external dependency that has nothing to do with the data.

Log what the run actually did

Row counts at each boundary, what was filled, what was quarantined, how long it took: five or six lines per run turn a question about a number into a lookup. The value is not in reading them on a good day but in having them on a bad one, when a total has moved and nobody can say whether the source changed, the cleaning changed, or a filter was added. A job that records its own behaviour is one that can be debugged after the fact rather than re-run and watched.

An image is the least portable thing in a workbook

Of everything a generated report can contain, drawings survive the fewest operations: a pandas rewrite removes them, copy_worksheet does not carry them, and reading the file into a frame ignores them entirely. That fragility is the reason images belong at the very end of the build and the reason a job that edits an existing workbook should use openpyxl throughout rather than round-tripping through pandas.

It is also worth asking whether the image is needed at all. A logo makes a report feel official; it adds nothing a reader uses, and on a workbook produced daily it is a cost paid every run.

Frequently asked questions

Why isn't my image in ws["A1"].value? Because an Excel image is never a cell value. It is a floating drawing in a separate layer that is merely anchored to A1. ws["A1"].value reads the cell's data; the image lives in ws._images, and Excel renders it on top of the grid.

Do I really need Pillow? Yes. openpyxl uses Pillow to read the image's dimensions and validate its format. Constructing openpyxl.drawing.image.Image(...) without Pillow installed raises ImportError immediately, even for a plain PNG.

My logo overlaps the data — how do I push the table down? You cannot push cells with an image; it floats. Instead reserve space: increase ws.row_dimensions[1].height and write your data starting a few rows lower (startrow= in pandas, or just append below row 1 in openpyxl).

Are the width and height in pixels or Excel units?img.width and img.height are in pixels and control the on-sheet display size, independent of the source file's resolution. Column widths and row heights use Excel's own character/point units, which is why aligning them takes a conversion factor.

Will the logo survive if I reopen and resave with openpyxl? Yes. load_workbook reads existing drawings and wb.save() writes them back. Only tools that rebuild the sheet from scratch — chiefly pandas.to_excel — drop the image.

Conclusion

An Excel image is a floating drawing anchored to a cell, not a cell value — internalize that and the rest follows. Build an openpyxl.drawing.image.Image, size it in pixels, anchor it with ws.add_image, and reserve space with row height and a merged title since the float never moves cells. Because pandas cannot embed images and erases them on rewrite, always make openpyxl the last hand to touch the file.

Where to go next

Up to the parent guide:

Go deeper here:

Sibling clusters: