Inserting Images and Logos into Excel
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.
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.
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.
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.
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.
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.
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.
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.
| Symptom | Cause | Fix |
|---|---|---|
| Logo gone after a later pandas write | to_excel rebuilds the sheet from the DataFrame and discards drawings | Make the openpyxl image step the last write to the file |
ImportError on Image(...) | Pillow not installed | pip install pillow |
| Logo overlaps the data table | Images float; they never push cells down | Reserve a band with row_dimensions[1].height and startrow |
| Image missing from saved file | Source 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:
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.
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:
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:
- Formatting and Charting Excel Reports with Python — the full polished-report track.
Go deeper here:
- Add a Logo Image to an Excel Report with openpyxl — the complete step-by-step header build with aspect-safe sizing.
Sibling clusters:
- Styling Excel Cells with openpyxl — fonts, fills, borders, and column widths to match your branded header.
- Applying Number and Date Formats in Excel — currency, percentage, and date display codes.
- Creating Charts in Excel with openpyxl — native charts that, like images, must survive the same pandas-last rule.