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

Output of list_layers.py on the sample PSD (psd-tools 1.26.0).
What each field means:
-
kindis the layer type as a short string:pixel,group,type,shape,smartobjectand so on (full list below). -
bboxis(left, top, right, bottom)in canvas pixels.layer.offsetgives(left, top)andlayer.sizegives width and height. -
opacityruns from 0 to 255, not 0 to 100. 255 is the "100%" you see in Photoshop. -
blend_modeis aBlendModeenum. Use.nameto getNORMALinstead ofBlendMode.NORMAL. -
color_modeis anIntEnum, so in an f-string it prints as3. Use.nameto getRGB.
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'.

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).

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}")

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(), nottopil(), 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}")

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.kindisdatawhen the file is embedded, andexternaloraliaswhen it's linked. A linked file isn't in the PSD at all; psd-tools only reads it from the working directory unless you passexternal_dir=. -
so.save()with no arguments writes to the current directory underso.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 ownfilename=. -
so.transform_boxgives 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:
-
Use
ignore_preview=Truewhen 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. -
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.textattribute. - 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.
Top comments (0)