How-to
Formatting And Charting Excel Reports With PythonDeep dive

Embed Images Inside Cells with xlsxwriter

Put a product photo or status icon inside a cell rather than floating over the grid, so it sorts, filters and resizes with its row. Use xlsxwriter's embed_image, and know how it differs from insert_image.

A product catalogue with a photo column looks simple until someone sorts it by price. Pictures inserted the traditional way float on a drawing layer above the grid, anchored to a cell but not part of it, so the rows move and the photos stay where they were — every product now wears somebody else's picture. Current versions of Excel can instead place a picture in a cell, as its value, and xlsxwriter writes that with embed_image(). The picture then sorts, filters, copies and resizes with its row like any other value. This guide, part of Inserting Images and Logos into Excel, shows how to use it and when the floating kind is still the right choice.

Floating images versus images embedded in cells A floating image from insert_image sits on the drawing layer, anchored to a cell, and can be positioned and sized freely but does not sort with rows. An embedded image from embed_image is the value of a cell, sorts and filters with its row, and is sized by the cell. insert_image (floating) on the drawing layer free position and scale stays put when rows sort logos, headers, charts embed_image (in cell) the cell value itself sized by row and column sorts and filters with row catalogues, status icons if the picture belongs to a row, embed it

Prerequisites

Bash
pip install xlsxwriter pillow pandas openpyxl

embed_image is available in recent xlsxwriter releases (it is present in the 3.2 series used here). Readers need a version of Excel that supports pictures in cells, such as Excel for Microsoft 365; older versions show #VALUE! in those cells, which is covered in Step 5. The snippet below draws two small placeholder product images with Pillow so the examples run without any assets:

Python
from PIL import Image, ImageDraw

for name, colour in [("widget", "#0f9488"), ("gadget", "#5b5cf0")]:
    picture = Image.new("RGB", (240, 240), "white")
    ImageDraw.Draw(picture).ellipse((20, 20, 220, 220), fill=colour)
    picture.save(f"{name}.png")

Step 1 — Embed an image in a cell

embed_image(row, col, source, options) takes zero-based row and column numbers (or an A1-style reference) and a filename. The image is scaled to fit the cell while keeping its aspect ratio, so the cell's size decides how large it appears.

Python
import xlsxwriter

workbook = xlsxwriter.Workbook("catalog.xlsx")
sheet = workbook.add_worksheet("Products")
header = workbook.add_format({"bold": True, "bottom": 1})

sheet.write_row(0, 0, ["SKU", "Name", "Price", "Photo"], header)
sheet.set_column("A:C", 14)
sheet.set_column("D:D", 12)

products = [("W-100", "Widget", 19.99, "widget.png"), ("G-200", "Gadget", 34.50, "gadget.png")]
for row, (sku, name, price, photo) in enumerate(products, start=1):
    sheet.set_row(row, 60)                     # height in points; the picture fits inside
    sheet.write_row(row, 0, [sku, name, price])
    sheet.embed_image(row, 3, photo, {"description": f"{name} product photo"})

workbook.close()

Setting the row height before embedding is what makes the result look deliberate. A default-height row produces a thumbnail about the size of a letter; 60 points gives a readable picture without making the table unwieldy. The description becomes the image's alternative text for screen readers, which is worth filling in for any report that is shared widely.

Step 2 — Size rows and columns for the pictures

Because an embedded picture fits itself to its cell, sizing is entirely a question of column width and row height. Column width is measured in character units and row height in points, so a square cell needs a little arithmetic: at default font settings one character unit is roughly seven pixels and one point is four-thirds of a pixel.

Python
def square_cell(sheet, row: int, col: int, pixels: int) -> None:
    sheet.set_column(col, col, pixels / 7)        # approximate character units
    sheet.set_row(row, pixels * 0.75)             # points

workbook = xlsxwriter.Workbook("square.xlsx")
sheet = workbook.add_worksheet()
for row in range(1, 3):
    square_cell(sheet, row, 1, 90)
sheet.embed_image(1, 1, "widget.png")
sheet.embed_image(2, 1, "gadget.png")
workbook.close()

The picture keeps its proportions inside a non-square cell, leaving blank space on the longer side. For consistent results, pre-process source images to the same aspect ratio before embedding — cropping product photos to squares is a two-line job with Pillow's ImageOps.fit. The general column and row sizing rules are in Set Column Width and Row Height in openpyxl.

A catalogue table with a picture in each row Columns A to C hold SKU, name and price; column D holds an embedded photo. Rows are 60 points tall so the pictures are legible, and sorting by price moves each photo with its product. A B C D 1 SKU Name Price Photo 2 W-100 Widget 19.99 [picture] row height 60 pt 3 G-200 Gadget 34.50 [picture] 4 T-300 Tool 12.00 [picture] sorts with its row the picture is the cell value, so it travels with the row

Step 3 — Embed images from bytes

Reports rarely have image files lying on disk. Photos come from a database, an object store or an HTTP API, and writing them to temporary files just to embed them is wasteful. Pass a BytesIO object as image_data in the options, together with a filename argument that only serves to identify the image type.

Python
from io import BytesIO

def png_bytes(colour: str) -> BytesIO:
    picture = Image.new("RGB", (240, 240), "white")
    ImageDraw.Draw(picture).rectangle((30, 30, 210, 210), fill=colour)
    buffer = BytesIO()
    picture.save(buffer, format="PNG")
    buffer.seek(0)
    return buffer

workbook = xlsxwriter.Workbook("from_bytes.xlsx")
sheet = workbook.add_worksheet()
sheet.set_column("B:B", 12)
for row, colour in enumerate(["#f43f8f", "#b4740a", "#0f9488"], start=1):
    sheet.set_row(row, 60)
    sheet.write(row, 0, colour)
    sheet.embed_image(row, 1, f"swatch{row}.png", {"image_data": png_bytes(colour)})
workbook.close()

xlsxwriter stores each distinct image once, even when it is embedded in many cells, so a status icon repeated down a column of thousands of rows adds only one copy of the picture to the file. The same BytesIO approach works for matplotlib figures, as shown in Embed a matplotlib Chart in an Excel Report.

Adding a url option makes the cell a hyperlink as well as a picture — useful for linking a thumbnail to a full-size photo or a product page. xlsxwriter applies its default hyperlink format unless you pass a cell_format.

Python
workbook = xlsxwriter.Workbook("linked.xlsx")
sheet = workbook.add_worksheet()
sheet.set_row(1, 60)
sheet.set_column("A:A", 12)
sheet.embed_image(1, 0, "widget.png", {
    "url": "https://example.com/products/W-100",
    "description": "Widget product photo, links to the product page",
})
workbook.close()

Hyperlinks in general, including links to other sheets and cells, are covered in Add Hyperlinks to Excel Cells with Python.

Step 5 — Plan for readers and tools that do not support it

A picture in a cell is stored as a rich value, a newer part of the file format. Three consequences follow, and each is worth deciding about before a report goes out.

  • Older Excel versions show #VALUE! in those cells, because xlsxwriter stores that error as the fallback value. If your readers include people on perpetual-licence Excel, use floating images instead.
  • pandas reads nothing for those cells: they come back as missing values, so an embedded-image column never pollutes data reads.
  • openpyxl drops embedded pictures when it loads and re-saves the workbook. It sees #VALUE! in the cell and does not keep the rich value parts, so any post-processing step must happen before xlsxwriter writes the file, not after.
Python
import pandas as pd
from openpyxl import load_workbook

print(pd.read_excel("catalog.xlsx"))                 # Photo column is NaN
print(load_workbook("catalog.xlsx")["Products"]["D2"].value)   # '#VALUE!'
How different readers see an embedded image cell Current Microsoft 365 Excel shows the picture. Older Excel versions show the VALUE error. pandas reads a missing value. openpyxl reads the VALUE error string and loses the picture if it saves the file. what each reader gets from the Photo column Excel for Microsoft 365 the picture older Excel versions #VALUE! pandas read_excel NaN openpyxl load + save picture lost write with xlsxwriter last; never round-trip through openpyxl

Step 6 — Use floating images where they fit better

Floating images from insert_image remain the right tool for anything that does not belong to a row: a logo in the header area, a chart image beside a table, a signature block. Their object_position option controls how they respond to row and column changes — 1 moves and sizes with cells, 2 moves but does not size, and 3 neither moves nor sizes.

Python
workbook = xlsxwriter.Workbook("mixed.xlsx")
sheet = workbook.add_worksheet()
sheet.set_row(0, 50)
sheet.insert_image("A1", "gadget.png", {"x_scale": 0.2, "y_scale": 0.2, "object_position": 3})
sheet.write("C1", "Quarterly product catalogue")
sheet.set_row(2, 60)
sheet.set_column("D:D", 12)
sheet.write_row(2, 0, ["W-100", "Widget", 19.99])
sheet.embed_image(2, 3, "widget.png")
workbook.close()

Mixing both in one sheet is normal: the logo floats and stays at the top, while product photos are embedded and follow their rows. For logo placement and scaling in detail, see Add a Logo Image to an Excel Report with openpyxl.

Common pitfalls

SymptomCauseFix
Tiny thumbnailsDefault row heightset_row to 50–80 points before embedding
#VALUE! instead of picturesReader's Excel does not support pictures in cellsUse insert_image, or require Microsoft 365
Pictures disappear after a later stepWorkbook re-saved with openpyxlDo all processing before xlsxwriter closes the file
Photos mismatched after sortingImages were floating, not embeddedUse embed_image for row-bound pictures
File much larger than expectedFull-resolution photosResize images to display size before embedding
AttributeError: embed_imageOld xlsxwriter releaseUpgrade xlsxwriter

Performance and scale notes

File size is driven by the images themselves. A 4,000-pixel product photo displayed in a 90-pixel cell carries the full resolution into the workbook, so downscale to roughly twice the display size with Pillow first. Identical images are stored once, which keeps repeated icons cheap. Large catalogues with hundreds of distinct photos are still slow to open in Excel regardless of how they were written, so consider linking to a photo per row instead of embedding every image when the list runs into the thousands.

Conclusion

Use embed_image whenever a picture belongs to a row — product photos, status icons, per-row thumbnails — so it sorts, filters and copies with its data. Size rows and columns first, feed images from BytesIO when they come from a database or API, add descriptions for accessibility, and downscale large photos. Keep insert_image for logos and page furniture, and remember the compatibility rules: older Excel shows #VALUE!, pandas reads nothing, and openpyxl must never re-save a workbook that contains embedded pictures.

Frequently asked questions

Can openpyxl embed images inside cells? No. openpyxl adds floating images only, and it drops pictures in cells when it re-saves a workbook that contains them. Use xlsxwriter for embedded images.

Does the image resize when I change the row height in Excel? Yes. An embedded picture is fitted to its cell, so making the row taller or the column wider enlarges it, keeping its aspect ratio.

Can I embed the same image in thousands of cells? Yes. xlsxwriter stores each distinct image once and references it from every cell, so repeated icons add very little to the file size.

What image formats are supported? PNG and JPEG are the safe choices for embedded pictures. Convert other formats with Pillow before embedding.

More on charts & images

More on charts & images →