Dev.to WebDev 🛠 Dev 👁 0 📖 13 min read

How to Read PSD Layers in Python with psd-tools

To read PSD layers in Python, install psd-tools, open the file with PSDImage.open(), and loop over psd.descendants(). Each layer gives you its name, kind, bbox, visible, opacity and blend_mode, and layer.composite() retu

How to Read PSD Layers in Python with psd-tools

To read PSD layers in Python, install psd-tools, open the file with PSDImage.open(), and loop over psd.descendants(). Each layer gives you its name, kind, bbox, visible, opacity and blend_mode, and layer.composite() returns it as a Pillow image you can save as a transparent PNG.

This post walks through psd-tools on a real file and shows the output of every snippet. It also covers two things the official quick start doesn't mention. Smart object layers carry their own embedded file, which can be larger than what you see on the canvas. And by default psd.composite() hands back the merged image saved in the file instead of rendering the layers.

Everything below was run with psd-tools 1.26.0 (released 2026-10-21), Pillow 13.0.0 and Python 3.13 on macOS. The sample is a 3-layer, 880×1216 poster PSD exported by IntoLayers (more on that at the end), not by Photoshop. The scripts call it poster.psd.

Install psd-tools

psd-tools is an MIT-licensed Python package for reading and writing Adobe Photoshop PSD and PSB files. Version 1.26.0 needs Python 3.11 or newer and pulls in Pillow and NumPy:

pip install psd-tools
# optional: vector shapes, gradient and pattern fills, layer effects
pip install "psd-tools[composite]"

The psd-tools composite extra adds aggdraw and scipy. Everything in this post, smart object layers included, ran without it.

If a search sends you to psd-tools2 or psd-tools3, skip them. They are old forks: psd-tools3 last shipped 1.9.2 in February 2022, while the original psd-tools project on GitHub shipped 1.26.0 this month. The import name is psd_tools with an underscore.

Open a PSD and list every layer

PSDImage.open() reads the file, and descendants() walks every layer, including the ones inside groups. Plain iteration (for layer in psd) only gives you the top level, which is what the official README example and the older Stack Overflow answers use.

from psd_tools import PSDImage

psd = PSDImage.open("poster.psd")
print(psd.size, psd.color_mode.name, f"{psd.depth}-bit")

for layer in psd.descendants():
    print(f"{layer.kind:<11} {layer.name!r:<38} bbox={layer.bbox} "
          f"visible={layer.visible} opacity={layer.opacity} "
          f"blend={layer.blend_mode.name}")

Output:

(880, 1216) RGB 8-bit
pixel       'Background'                           bbox=(0, 0, 880, 1216) visible=True opacity=255 blend=NORMAL
smartobject 'Monstera potted plant illustration'   bbox=(110, 474, 734, 1139) visible=True opacity=255 blend=NORMAL
smartobject 'Poster text and badge elements'       bbox=(123, 0, 879, 422) visible=True opacity=255 blend=NORMAL

Terminal output of a psd-tools script listing three layers, a pixel layer named Background and two smart object layers named Monstera potted plant illustration and Poster text and badge elements, with their bbox, visibility, opacity and blend mode
Output of list_layers.py on the sample PSD (psd-tools 1.26.0).

What each field means:

  • kind is the layer type as a short string: pixel, group, type, shape, smartobject and so on (full list below).
  • bbox is (left, top, right, bottom) in canvas pixels. layer.offset gives (left, top) and layer.size gives width and height.
  • opacity runs from 0 to 255, not 0 to 100. 255 is the "100%" you see in Photoshop.
  • blend_mode is a BlendMode enum. Use .name to get NORMAL instead of BlendMode.NORMAL.
  • color_mode is an IntEnum, so in an f-string it prints as 3. Use .name to get RGB.

Iteration runs from background to foreground. That's the reverse of what the Layers panel shows. Use reversed(psd) if you want panel order.

For a cross-check, here is the same file in Photopea. The panel lists the same three layers, top to bottom, and cuts off the long names. psd-tools gives you the full strings. The small badge on the two upper thumbnails is Photopea's smart object marker, which matches kind == 'smartobject'.

Photopea editor with image-to-layers.psd open and the Layers panel showing three layers, Poster text and badge elements, Monstera potted plant illustration and Background, with smart object badges on the top two thumbnails and long names truncated
Screenshot: the same PSD opened in Photopea, October 2026

What bbox and offset tell you

A layer's pixels only cover its bounding box, not the whole canvas. In this file only the background fills the canvas. The plant layer is 624×665 pixels placed at (110, 474). The text layer is 756×422 at (123, 0).

Diagram of an 880 by 1216 canvas with three outlined bounding boxes: the background covering the full canvas, the plant layer at 110, 474 to 734, 1139, and the text layer at 123, 0 to 879, 422
Each layer's bbox on the canvas, drawn from the script output. Boxes only, no layer pixels.

This matters as soon as you export. If you build sprites or web assets from layers, store the offset next to each PNG, or you lose the position.

Extract every layer as a transparent PNG

layer.composite() renders one layer, with its mask and clipping layers, as an RGBA Pillow image cropped to its bbox. The script below skips groups, names files by index plus a slug of the layer name, and prints what it wrote:

import re
from pathlib import Path
from psd_tools import PSDImage

psd = PSDImage.open("poster.psd")
out = Path("layers")
out.mkdir(exist_ok=True)

for i, layer in enumerate(psd.descendants()):
    if layer.is_group():
        continue
    image = layer.composite()          # RGBA, cropped to layer.bbox
    if image is None:                  # empty layer, nothing to draw
        continue
    slug = re.sub(r"[^a-z0-9]+", "-", layer.name.lower()).strip("-")
    path = out / f"{i:02d}-{slug}.png"
    image.save(path)
    print(f"{path}  {image.mode}  {image.size}  offset={layer.offset}")

Terminal output of export_layers.py writing three RGBA PNG files with their sizes and offsets, followed by the file sizes in bytes
Each layer exported as a transparent PNG, cropped to its bbox.

Two choices here save you trouble later:

  • Don't use the raw layer name as a file name. Layer names are free text. Two layers can share a name, and psd-tools happily accepts a name like 'Logo / v2'. The README example saves to "%s.png" % layer.name, which fails on the first slash.
  • Use composite(), not topil(), for anything you'll show. topil() returns the raw pixel data without applying the mask. composite() applies the mask and clipping. If you'd rather work in NumPy, layer.numpy() gives the raw pixels as floats from 0 to 1. For the plant layer that's a (665, 624, 4) float32 array.

If you need every layer at full canvas size, for example to stack them in CSS with no offsets, pass the document viewbox:

placed = plant.composite(viewport=psd.viewbox)  # 880x1216, transparent around the plant

Get the original file out of a smart object

A smart object layer wraps a whole embedded file, and Photoshop draws it scaled into the layer's box. layer.composite() gives you what's on the canvas. layer.smart_object gives you the embedded file itself, at its own size:

from pathlib import Path
from PIL import Image
from psd_tools import PSDImage

psd = PSDImage.open("poster.psd")
out = Path("originals")
out.mkdir(exist_ok=True)

for layer in psd.descendants():
    if layer.kind != "smartobject":
        continue
    so = layer.smart_object
    so.save(directory=out)                  # writes the embedded file under its own name
    with so.open() as f:                    # file-like object over the same bytes
        embedded = Image.open(f).size
    print(f"{so.filename}: {so.kind} {so.filetype} "
          f"embedded={embedded} placed={layer.size}")

Terminal output of smart_objects.py showing two embedded PNG files, the plant at 769 by 821 inside a 624 by 665 box and the text block at 1044 by 584 inside a 756 by 422 box
Each smart object holds a PNG larger than the box it is placed in.

In this file both embedded PNGs are bigger than their boxes: the plant is 769×821 inside a 624×665 box, and the text block is 1044×584 inside 756×422. If you're building assets for retina screens, or you want to scale a layer up later, save the embedded file instead of the canvas render.

A few things to know:

  • so.kind is data when the file is embedded, and external or alias when it's linked. A linked file isn't in the PSD at all; psd-tools only reads it from the working directory unless you pass external_dir=.
  • so.save() with no arguments writes to the current directory under so.filename. psd-tools strips path tricks like ../ from that name, but it's still whatever the file says, so two layers can overwrite each other. In a server, pass your own filename=.
  • so.transform_box gives the four corners on the canvas. Here it's a plain rectangle, (110, 474) to (734, 1139) for the plant. A rotated or skewed smart object won't be.

Convert the whole PSD to PNG

psd.composite() returns the full image. By default, though, psd-tools doesn't render anything when the file has a stored merged image and you haven't edited it. It returns that stored image as is. In psd-tools 1.26.0 the check is has_preview() and not is_updated(). Passing ignore_preview=True, force=True or a layer_filter skips it.

You can see the difference on the sample file:

from psd_tools import PSDImage

psd = PSDImage.open("poster.psd")

preview = psd.composite()
print("default :", preview.mode, preview.size, preview.getextrema())

render = psd.composite(ignore_preview=True)
print("rendered:", render.mode, render.size, render.getextrema()[:3])
render.save("poster.png")
default : RGB (880, 1216) ((0, 255), (2, 255), (5, 254))
rendered: RGBA (880, 1216) ((0, 255), (2, 255), (4, 254))

Here the two agree: no pixel differs by more than 1 out of 255. The default call is just reading the stored image, while the second one actually draws the three layers. The psd-tools command line tool takes the same shortcut, so psd-tools export poster.psd out.png wrote the stored RGB image. macOS Quick Look also shows that stored image. It can't show layers.

Two rules follow from this:

  1. Use ignore_preview=True when the layers are what you care about. The stored image is whatever the last app to save the file wrote. If a tool edits the layers and doesn't refresh it, the default call returns the old picture. Rendering costs more time, but the output always matches the layers.
  2. Don't assume the mode. The stored image came back RGB and the render came back RGBA. Call .convert("RGB") before saving a JPEG.

The psd-tools renderer isn't Photoshop's engine. Its docs say most layer effects aren't supported, and only some adjustment layers are (Brightness/Contrast, Levels, Curves, Exposure, Invert, Posterize, Threshold). Check the output for files that use effects.

Batch convert a folder of PSDs to PNG

The ignore_preview=True rule matters even more in a batch, because one file with a stale stored image is easy to miss among hundreds. The loop matches .psd and .psb in any letter case, and wraps each file in try, so one broken upload doesn't stop the run:

from pathlib import Path
from psd_tools import PSDImage

src, out = Path("psds"), Path("png")
out.mkdir(exist_ok=True)

for path in sorted(p for p in src.iterdir() if p.suffix.lower() in {".psd", ".psb"}):
    try:
        psd = PSDImage.open(path)
        psd.composite(ignore_preview=True).save(out / f"{path.stem}.png")
        print("ok  ", path.name, psd.size)
    except Exception as err:   # one bad file shouldn't stop the batch
        print("fail", path.name, type(err).__name__)

I ran it on a folder holding the sample and a copy I cut off after 200 KB on purpose:

ok   poster.psd (880, 1216)
fail truncated.psd OSError

Hide a layer and render again

You can leave a layer out in two ways. A layer_filter decides at render time and leaves the document alone. Setting visible = False changes the document, and that change also turns off the stored-image shortcut:

from psd_tools import PSDImage

psd = PSDImage.open("poster.psd")
text = next(l for l in psd.descendants() if l.name.startswith("Poster text"))

# Option 1: filter at render time; the document is left alone
img = psd.composite(layer_filter=lambda l: l.is_visible() and l is not text)
print("filtered:", img.mode, img.size, "| edited:", psd.is_updated())

# Option 2: hide the layer, which marks the document as edited
text.visible = False
print("hidden  : edited:", psd.is_updated())
psd.composite().save("poster-no-text.png")
filtered: RGBA (880, 1216) | edited: False
hidden  : edited: True

Both outputs were pixel-identical on this file. Use the filter when you render several variants from one open file, such as a version without text for each language.

One detail: visible is the layer's own flag, while is_visible() also checks whether the parent group is visible. Filter on is_visible().

Crop to a layer's bbox and make thumbnails

The bbox is also a ready-made crop box for the rendered page. That gives you three different images of the same layer:

page = psd.composite(ignore_preview=True)
plant = psd[1]

alone  = plant.composite()                       # the layer by itself, trimmed
placed = plant.composite(viewport=psd.viewbox)   # the layer by itself, full canvas
region = page.crop(plant.bbox)                   # the finished page, cut to that box

thumb = page.copy()
thumb.thumbnail((256, 256))
alone  RGBA (624, 665)
placed RGBA (880, 1216)
region RGBA (624, 665)
thumb  RGBA (185, 256)

alone and region are the same size but different images. region includes whatever sits behind and in front of the plant. thumbnail() keeps the aspect ratio, so 880×1216 becomes 185×256.

How psd-tools handles groups, smart objects, text and adjustment layers

In psd-tools, layer.kind is the class name in lowercase with "layer" removed. That's the rule in psd_tools/api/layers.py in 1.26.0. My sample file only has pixel and smart object layers, so the other rows in the table below come from the docs and the source, not from this file:

What it is in Photoshop kind What to know
Normal raster layer pixel composite() works as shown above
Layer group (folder) group No pixels of its own. Its bbox is the union of its children. Iterate it, or use descendants()
Text layer type layer.text gives the string, layer.typesetting the fonts and sizes. Font rendering is listed as not supported, so check text layers in the composite output
Vector shape shape Needs psd-tools[composite] to draw strokes and fills
Smart object smartobject composite() draws it at its box size; layer.smart_object gives the embedded file at full size (see above)
Fill layer solidcolorfill, gradientfill, patternfill Gradients and patterns need the composite extra
Adjustment layer the adjustment's name, e.g. brightnesscontrast, curves, huesaturation There's no single adjustment kind. Test with isinstance(layer, AdjustmentLayer), imported from psd_tools.api.layers

To see groups for real, I wrapped the two artwork layers in a group in memory (the file on disk isn't changed):

group = psd.create_group(layer_list=[psd[1], psd[2]], name="Artwork")
print("top level  :", [layer.name for layer in psd])
print("descendants:", [layer.name for layer in psd.descendants()])
print("group:", group.kind, group.bbox, group.has_pixels())
top level  : ['Background', 'Artwork']
descendants: ['Background', 'Artwork', 'Monstera potted plant illustration', 'Poster text and badge elements']
group: group (110, 0, 879, 1139) False

A top-level loop would have exported "Artwork" and skipped both children. descendants() returns the group itself too, which is why the export script skips is_group().

Reading PSDs uploaded by users

If your backend opens PSDs that strangers upload, set psd-tools parse limits. A PSD can declare far more pixels than it contains, so a small upload can ask for gigabytes when rendered. psd-tools caps each estimated allocation at 4 GiB by default and has parse limits you can tighten:

from psd_tools import ParseLimits, PSDImage

psd = PSDImage.open(
    "poster.psd",
    parse_limits=ParseLimits(max_read_bytes=64 * 1024**2,
                             max_total_bytes=256 * 1024**2),
    max_alloc_bytes=512 * 1024**2,
)

Going over a limit raises an error you can catch and turn into a 4xx response. Byte limits are off by default, so set them yourself. The Handling untrusted files page lists every setting.

What if you only have a flattened PNG?

psd-tools can only read layers that already exist in the file. A flattened PNG or JPEG has no layer records, so there is nothing for descendants() to return. You can cut the elements out by hand in an editor. Or you can run the flat file through an image to layers converter first, then point the scripts above at the PSD it gives you.

One option is IntoLayers, which I work on. It is an AI image-to-layers converter that splits a flattened PNG or JPEG into a rebuilt background plus a separate transparent layer for each element — text, subjects and graphics — and exports them as a layered PSD, transparent PNGs or a layered SVG. What to expect before you wire it into a pipeline:

  • Input is one PNG or JPEG per run, up to 10 MB.
  • You choose 2, 3, 5, 7 or 9 layers, or Auto (2–17). The background counts as one.
  • Text comes back as an image, not live type. In the PSD each element is a smart object holding a PNG, like the two layers above, so in psd-tools a text element is kind == 'smartobject' with no .text attribute.
  • Anything that was hidden behind another element is a guess, not a recovery.
  • Your first conversion (1K, 2 layers) runs without an account.

Other ways to read PSD layers

psd-tools is the usual pure-Python choice, but it isn't the only one:

Tool Language Good for Catch
psd-tools Python Reading layer data, exporting PNGs, light edits Its rendering isn't Photoshop's; most effects aren't drawn
ImageMagick / Wand CLI, Python Quick file.psd[N] frame extraction Layer frames come out trimmed, and you work by index, not by name
Photoshop scripting (win32com) Python on Windows Exact Photoshop output Needs a licensed Photoshop running
Aspose.PSD for Python Python (.NET) Commercial SDK with editing Paid licence; a temporary licence is offered for trials
ag-psd JavaScript Reading and writing PSDs from JavaScript Not Python; its README says text layer support is incomplete

FAQ

Can psd-tools edit text in a PSD?

No. You can read a text layer's string and styles, but the README lists editing text in type layers and font rendering as not supported. It has limited support for editing layer names and for adding or removing pixel layers and groups.

Why is my psd.composite() output black or different from Photoshop?

psd.composite() returns the merged image stored in the file unless you've edited the document. If that stored image is blank or out of date, so is your output. Pass ignore_preview=True to render from the layers. Remaining differences usually come from layer effects psd-tools doesn't draw.

Can psd-tools open PSB (large document) files?

Yes. The 1.26.0 source describes PSDImage as a "Photoshop PSD/PSB document", and psd.version tells you which one you opened: 1 for PSD, 2 for PSB. I only had a PSD to test with, so the snippets above were run on PSD alone.

Does psd-tools need Photoshop installed?

No. psd-tools reads the file format itself, so Photoshop is never involved. You need Python 3.11 or newer. Photopea or GIMP are handy for checking output by eye.

How do I convert a PSD to PNG in Python?

PSDImage.open("file.psd").composite(ignore_preview=True).save("file.png"). For one PNG per layer, use the export loop above.

Disclosure: I work on IntoLayers.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.