How-to
Formatting And Charting Excel Reports With PythonDeep dive

Extract Images from an Excel File with Python

Pull every picture out of an .xlsx — all at once from the zip archive, floating images with the cell they sit on through openpyxl, and pictures placed in cells by following the rich-value parts.

Workbooks collect pictures: product photos pasted into a catalogue, scanned receipts attached to an expenses sheet, signatures on an approval form. Sooner or later someone needs them out — to load into a product database, to archive, or to check that every row has one. Excel's own answer is to save the workbook as a web page and dig through a folder. Python can do better, and can keep the link between each picture and the row it belongs to. This guide, part of Inserting Images and Logos into Excel, covers the three levels of detail you might need.

Where pictures live inside an xlsx file An xlsx file is a zip archive. Every picture is stored under xl/media. Floating pictures are positioned by drawing parts linked to each worksheet. Pictures in cells are referenced through cell metadata and rich value parts under xl/richData. the zip archive an .xlsx is a zip of XML parts and media files xl/media/ every image file, whatever kind of picture it is xl/drawings/ floating pictures: anchors to a cell, size, offset xl/richData/ pictures in cells: linked from the cell value metadata the media folder has the bytes; the other parts say where they go

Prerequisites

Bash
pip install openpyxl pillow xlsxwriter

This builds a workbook containing one floating logo and two pictures placed inside cells, so every technique on the page has something to find:

Python
import xlsxwriter
from PIL import Image, ImageDraw

for name, colour in [("logo", "#4338ca"), ("widget", "#0f9488"), ("gadget", "#b4740a")]:
    picture = Image.new("RGB", (200, 200), "white")
    ImageDraw.Draw(picture).ellipse((20, 20, 180, 180), fill=colour)
    picture.save(f"{name}.png")

book = xlsxwriter.Workbook("catalog.xlsx")
sheet = book.add_worksheet("Products")
sheet.insert_image("F1", "logo.png", {"x_scale": 0.3, "y_scale": 0.3})
sheet.write_row(0, 0, ["SKU", "Name", "Photo"])
for row, (sku, name) in enumerate([("W-100", "widget"), ("G-200", "gadget")], start=1):
    sheet.set_row(row, 60)
    sheet.write_row(row, 0, [sku, name])
    sheet.embed_image(row, 2, f"{name}.png")
book.close()

Step 1 — Extract every image from the archive

An .xlsx file is a zip archive, and every picture in it — floating or in a cell, on any sheet — is stored as an ordinary image file under xl/media/. When you only need the files, the standard library is enough.

Python
import zipfile
from pathlib import Path

def extract_all_media(xlsx: str, out_dir: str) -> list[Path]:
    target = Path(out_dir)
    target.mkdir(parents=True, exist_ok=True)
    saved = []
    with zipfile.ZipFile(xlsx) as archive:
        for info in archive.infolist():
            if info.filename.startswith("xl/media/"):
                path = target / Path(info.filename).name
                path.write_bytes(archive.read(info))
                saved.append(path)
    return saved

print(extract_all_media("catalog.xlsx", "media_out"))

This is fast and reliable, but it loses context: files are named image1.png, image2.png in the order the writing application chose, and one picture used in many cells appears only once. For an archive or a quick audit that is fine; for loading photos against products it is not.

Step 2 — Extract floating images with their cell anchors

Floating pictures are tied to a cell through an anchor in the sheet's drawing part. openpyxl loads them onto each worksheet, where the (private but stable) _images list gives access to both the bytes and the anchor. The anchor's _from marker holds zero-based row and column numbers.

Python
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter

def extract_floating(xlsx: str, out_dir: str) -> list[dict]:
    target = Path(out_dir)
    target.mkdir(parents=True, exist_ok=True)
    found = []
    for ws in load_workbook(xlsx).worksheets:
        for number, image in enumerate(ws._images, start=1):
            marker = image.anchor._from
            cell = f"{get_column_letter(marker.col + 1)}{marker.row + 1}"
            path = target / f"{ws.title}_{cell}_{number}.{image.format}"
            path.write_bytes(image._data())
            found.append({"sheet": ws.title, "cell": cell, "file": path.name})
    return found

print(extract_floating("catalog.xlsx", "floating_out"))
# [{'sheet': 'Products', 'cell': 'F1', 'file': 'Products_F1_1.png'}]

The anchor is the top-left cell the picture is attached to, which is what a person means by "the picture in F1". A picture that spans several cells still reports only its top-left corner. openpyxl needs Pillow installed to load images at all; without it, the images are silently skipped.

Choosing an extraction method If you only need the image files, read xl/media from the zip. If you need to know which cell a floating picture is attached to, use openpyxl image anchors. If pictures are placed inside cells, follow the cell metadata to the rich value relationships. what do you need to know? just the files zip xl/media fast, no context, duplicates merged floating picture cell openpyxl anchors ws._images, anchor._from picture in a cell rich value parts vm attribute → metadata → media openpyxl does not see pictures placed in cells

Step 3 — Map pictures in cells to their cells

Pictures placed in cells are invisible to openpyxl: the cell's value reads as #VALUE! and there is no image object. The link from cell to picture runs through four small XML parts. The cell carries a vm attribute (value metadata index, one-based); xl/metadata.xml maps that to a rich value index; xl/richData/rdrichvalue.xml gives the relationship index for that rich value; and xl/richData/richValueRel.xml with its .rels file resolves that to a media file. Following the chain with xml.etree is short.

Python
import re
import xml.etree.ElementTree as ET

NS = {
    "m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
    "rd": "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata",
    "rv": "http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
    "pr": "http://schemas.openxmlformats.org/package/2006/relationships",
}

def in_cell_image_map(xlsx: str, sheet_part: str = "xl/worksheets/sheet1.xml") -> dict[str, str]:
    with zipfile.ZipFile(xlsx) as z:
        names = set(z.namelist())
        if "xl/richData/rdrichvalue.xml" not in names:
            return {}
        meta = ET.fromstring(z.read("xl/metadata.xml"))
        future = [bk.find(".//rd:rvb", NS).get("i") for bk in meta.find("m:futureMetadata", NS).findall("m:bk", NS)]
        value_meta = [int(bk.find("m:rc", NS).get("v")) for bk in meta.find("m:valueMetadata", NS).findall("m:bk", NS)]

        structures = ET.fromstring(z.read("xl/richData/rdrichvaluestructure.xml")).findall("rd:s", NS)
        values = ET.fromstring(z.read("xl/richData/rdrichvalue.xml")).findall("rd:rv", NS)
        rel_ids = [rel.get(f"{{{NS['r']}}}id") for rel in ET.fromstring(z.read("xl/richData/richValueRel.xml"))]
        targets = {rel.get("Id"): rel.get("Target")
                   for rel in ET.fromstring(z.read("xl/richData/_rels/richValueRel.xml.rels")).findall("pr:Relationship", NS)}

        sheet_xml = z.read(sheet_part).decode("utf-8")
        mapping = {}
        for ref, vm in re.findall(r'<c r="([A-Z]+[0-9]+)"[^>]*\bvm="([0-9]+)"', sheet_xml):
            rich_index = int(future[value_meta[int(vm) - 1]])
            rich_value = values[rich_index]
            keys = [k.get("n") for k in structures[int(rich_value.get("s"))].findall("rd:k", NS)]
            rel_position = int(rich_value.findall("rd:v", NS)[keys.index("_rvRel:LocalImageIdentifier")].text)
            mapping[ref] = "xl/" + targets[rel_ids[rel_position]].replace("../", "")
        return mapping

print(in_cell_image_map("catalog.xlsx"))
# {'C2': 'xl/media/image1.png', 'C3': 'xl/media/image2.png'}

Notice that the media file numbers say nothing about position: here the in-cell pictures took image1 and image2 and the floating logo took image3, purely because of the order in which the writing library stored them. Another application saving the same workbook could renumber everything. That is the whole reason to follow the chain from the cell rather than guessing from file names.

The lookup of _rvRel:LocalImageIdentifier by name in the value's structure, rather than assuming it is the first value, keeps the function working for files saved by Excel as well as by xlsxwriter, since the order of keys in a structure is not guaranteed. The sheet_part argument names the worksheet's XML part; for workbooks with several sheets, resolve each sheet's part from xl/_rels/workbook.xml.rels.

Step 4 — Save files named after the row they belong to

With a map from cell to media path, extraction can name each file after something meaningful in the same row — a SKU, an employee ID, a receipt number — which is what a downstream import needs.

Python
def extract_by_key(xlsx: str, key_column: str, out_dir: str) -> list[str]:
    target = Path(out_dir)
    target.mkdir(parents=True, exist_ok=True)
    sheet = load_workbook(xlsx).worksheets[0]
    written = []
    with zipfile.ZipFile(xlsx) as z:
        for ref, media in in_cell_image_map(xlsx).items():
            row = int(re.sub(r"[A-Z]+", "", ref))
            key = sheet[f"{key_column}{row}"].value or f"row{row}"
            path = target / f"{key}{Path(media).suffix}"
            path.write_bytes(z.read(media))
            written.append(path.name)
    return written

print(extract_by_key("catalog.xlsx", "A", "photos_by_sku"))
# ['W-100.png', 'G-200.png']

Sanitise the key before using it in a filename if it can contain slashes or other characters that paths do not allow. And check for duplicates: two rows with the same SKU would overwrite each other's photo, which is itself a data problem worth reporting.

Following an in-cell picture from its cell to the image file The cell carries a vm attribute, metadata maps it to a rich value index, the rich value gives a relationship position, and the relationship file points at an image in the media folder. cell C2 vm="1" metadata.xml value → rich index rdrichvalue rich → rel position xl/media image2.png four small XML parts link a cell to its picture

Step 5 — Verify what you extracted

Image files pulled from office documents are occasionally truncated, in an unexpected format, or far larger than they appear on screen. Open each with Pillow before handing the folder to another system, and record the dimensions alongside the key.

Python
from PIL import Image

def audit(folder: str) -> list[dict]:
    report = []
    for path in sorted(Path(folder).iterdir()):
        with Image.open(path) as picture:
            picture.verify()
        with Image.open(path) as picture:
            report.append({"file": path.name, "format": picture.format, "size": picture.size})
    return report

for entry in audit("photos_by_sku"):
    print(entry)

verify() detects corrupt files without decoding the whole image, but leaves the image object unusable, which is why the file is opened a second time to read its size.

The audit is also the natural place to compare against the sheet. Count the rows that should have a picture — every product with a SKU, every expense line above a threshold — and compare with the number of files written. A catalogue where 212 of 220 products produced a photo tells you exactly which eight rows need attention, which is usually the real question behind "can you get the pictures out of this spreadsheet?". Writing the missing keys to a small CSV next to the image folder gives whoever owns the workbook a precise list to fix, instead of a vague report that some photos are missing.

Common pitfalls

SymptomCauseFix
ws._images is emptyPillow not installed, or pictures are in cellsInstall Pillow; use the rich value map for in-cell pictures
Fewer files than pictures on screenThe same image is reused in several placesUse anchors or the cell map to see every placement
KeyError: xl/metadata.xmlWorkbook has no pictures in cellsCheck for xl/richData/ first, as the function does
Wrong row for a floating pictureAnchor is the top-left corner onlyTreat the anchor as the picture's owning cell
Extracted images are .emf or .wmfPasted from older Office contentConvert with a tool such as LibreOffice or Inkscape
.xls files yield nothingLegacy binary format, not a zipConvert to .xlsx first

Performance and scale notes

Reading from the zip archive is fast regardless of workbook size, because the media files are read directly without parsing any cell data. The openpyxl route loads every cell of every sheet, which is slow for large workbooks when all you want is a handful of pictures. For a big workbook with in-cell pictures, the rich value map above avoids openpyxl for the images entirely — use a read_only openpyxl load, or pandas, just to read the key column.

Conclusion

Every picture in an .xlsx sits in xl/media/, so a zip read extracts the files instantly. When the position matters, use openpyxl's image anchors for floating pictures, and follow the cell's vm attribute through metadata.xml and the rich value parts for pictures placed in cells, which openpyxl does not load. Name the extracted files after a key from the same row, check for duplicate keys, and verify each image with Pillow before passing the folder on.

Frequently asked questions

Can pandas extract images from Excel? No. pandas reads cell values only; picture cells come back as missing values. Use zipfile or openpyxl as shown on this page.

Does this work for charts? Charts are not images; they are stored as chart XML parts and drawn by Excel. To get a chart as a picture, export it from Excel, or regenerate it with matplotlib from the underlying data.

How do I extract images from every sheet? For floating pictures the openpyxl loop already covers all worksheets. For pictures in cells, map each sheet name to its XML part through xl/workbook.xml and xl/_rels/workbook.xml.rels, then call the map function per part.

Why do some extracted images have a different resolution from what I see? Excel scales pictures for display but stores the original. The extracted file is the stored original, which is usually the better version to keep.

More on charts & images

More on charts & images →