Guide
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 in this cluster: 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.
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 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.
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 pillar:
- 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.