<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: libersand</title>
    <description>The latest articles on DEV Community by libersand (@libersand).</description>
    <link>https://dev.to/libersand</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081268%2F930d5362-ee32-4bd5-92b9-bc2f0e68e62f.png</url>
      <title>DEV Community: libersand</title>
      <link>https://dev.to/libersand</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/libersand"/>
    <language>en</language>
    <item>
      <title>How to Read PSD Layers in Python with psd-tools</title>
      <dc:creator>libersand</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:14:40 +0000</pubDate>
      <link>https://dev.to/libersand/how-to-read-psd-layers-in-python-with-psd-tools-36o1</link>
      <guid>https://dev.to/libersand/how-to-read-psd-layers-in-python-with-psd-tools-36o1</guid>
      <description>&lt;p&gt;To read PSD layers in Python, install &lt;code&gt;psd-tools&lt;/code&gt;, open the file with &lt;code&gt;PSDImage.open()&lt;/code&gt;, and loop over &lt;code&gt;psd.descendants()&lt;/code&gt;. Each layer gives you its &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;kind&lt;/code&gt;, &lt;code&gt;bbox&lt;/code&gt;, &lt;code&gt;visible&lt;/code&gt;, &lt;code&gt;opacity&lt;/code&gt; and &lt;code&gt;blend_mode&lt;/code&gt;, and &lt;code&gt;layer.composite()&lt;/code&gt; returns it as a Pillow image you can save as a transparent PNG.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;psd.composite()&lt;/code&gt; hands back the merged image saved in the file instead of rendering the layers.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;poster.psd&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install psd-tools
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;psd-tools
&lt;span class="c"&gt;# optional: vector shapes, gradient and pattern fills, layer effects&lt;/span&gt;
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"psd-tools[composite]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The psd-tools &lt;code&gt;composite&lt;/code&gt; extra adds &lt;code&gt;aggdraw&lt;/code&gt; and &lt;code&gt;scipy&lt;/code&gt;. Everything in this post, smart object layers included, ran without it.&lt;/p&gt;

&lt;p&gt;If a search sends you to &lt;code&gt;psd-tools2&lt;/code&gt; or &lt;code&gt;psd-tools3&lt;/code&gt;, skip them. They are old forks: &lt;code&gt;psd-tools3&lt;/code&gt; last shipped 1.9.2 in February 2022, while the original &lt;a href="https://github.com/psd-tools/psd-tools" rel="noopener noreferrer"&gt;psd-tools project on GitHub&lt;/a&gt; shipped 1.26.0 this month. The import name is &lt;code&gt;psd_tools&lt;/code&gt; with an underscore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open a PSD and list every layer
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psd_tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;

&lt;span class="n"&gt;psd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poster.psd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;color_mode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-bit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;descendants&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;!r:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bbox=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;visible=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;visible&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; opacity=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;opacity&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blend=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blend_mode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvv0lso472gp7o4fa54w4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvv0lso472gp7o4fa54w4.png" alt="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" width="799" height="134"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Output of list_layers.py on the sample PSD (psd-tools 1.26.0).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What each field means:&lt;/p&gt;

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

&lt;p&gt;Iteration runs from background to foreground. That's the reverse of what the Layers panel shows. Use &lt;code&gt;reversed(psd)&lt;/code&gt; if you want panel order.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;kind == 'smartobject'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx2pahlixlqz9z53lmhqz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx2pahlixlqz9z53lmhqz.png" alt="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" width="800" height="549"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Screenshot: the same PSD opened in Photopea, October 2026&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What bbox and offset tell you
&lt;/h2&gt;

&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8xl4bjrjw4x4cdeq3yrn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8xl4bjrjw4x4cdeq3yrn.png" alt="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" width="800" height="586"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Each layer's bbox on the canvas, drawn from the script output. Boxes only, no layer pixels.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Extract every layer as a transparent PNG
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;layer.composite()&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psd_tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;

&lt;span class="n"&gt;psd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poster.psd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;layers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;descendants&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_group&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# RGBA, cropped to layer.bbox
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                  &lt;span class="c1"&gt;# empty layer, nothing to draw
&lt;/span&gt;        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^a-z0-9]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  offset=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96yijt21fh5b71057xwj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96yijt21fh5b71057xwj.png" alt="Terminal output of export_layers.py writing three RGBA PNG files with their sizes and offsets, followed by the file sizes in bytes" width="799" height="242"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Each layer exported as a transparent PNG, cropped to its bbox.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two choices here save you trouble later:&lt;/p&gt;

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

&lt;p&gt;If you need every layer at full canvas size, for example to stack them in CSS with no offsets, pass the document viewbox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;placed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewbox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 880x1216, transparent around the plant
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Get the original file out of a smart object
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psd_tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;

&lt;span class="n"&gt;psd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poster.psd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;originals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;descendants&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;smartobject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;so&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;smart_object&lt;/span&gt;
    &lt;span class="n"&gt;so&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# writes the embedded file under its own name
&lt;/span&gt;    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;so&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                    &lt;span class="c1"&gt;# file-like object over the same bytes
&lt;/span&gt;        &lt;span class="n"&gt;embedded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;so&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;so&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;so&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filetype&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedded=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;embedded&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; placed=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fplotk0db012e6be7hufi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fplotk0db012e6be7hufi.png" alt="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" width="798" height="134"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Each smart object holds a PNG larger than the box it is placed in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;A few things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;so.kind&lt;/code&gt; is &lt;code&gt;data&lt;/code&gt; when the file is embedded, and &lt;code&gt;external&lt;/code&gt; or &lt;code&gt;alias&lt;/code&gt; 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 &lt;code&gt;external_dir=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;so.save()&lt;/code&gt; with no arguments writes to the current directory under &lt;code&gt;so.filename&lt;/code&gt;. psd-tools strips path tricks like &lt;code&gt;../&lt;/code&gt; from that name, but it's still whatever the file says, so two layers can overwrite each other. In a server, pass your own &lt;code&gt;filename=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;so.transform_box&lt;/code&gt; 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Convert the whole PSD to PNG
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;psd.composite()&lt;/code&gt; 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 &lt;code&gt;has_preview() and not is_updated()&lt;/code&gt;. Passing &lt;code&gt;ignore_preview=True&lt;/code&gt;, &lt;code&gt;force=True&lt;/code&gt; or a &lt;code&gt;layer_filter&lt;/code&gt; skips it.&lt;/p&gt;

&lt;p&gt;You can see the difference on the sample file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psd_tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;

&lt;span class="n"&gt;psd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poster.psd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;preview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default :&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getextrema&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ignore_preview&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rendered:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getextrema&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poster.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default : RGB (880, 1216) ((0, 255), (2, 255), (5, 254))
rendered: RGBA (880, 1216) ((0, 255), (2, 255), (4, 254))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;psd-tools export poster.psd out.png&lt;/code&gt; wrote the stored RGB image. macOS Quick Look also shows that stored image. It can't show layers.&lt;/p&gt;

&lt;p&gt;Two rules follow from this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;ignore_preview=True&lt;/code&gt; when the layers are what you care about.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't assume the mode.&lt;/strong&gt; The stored image came back RGB and the render came back RGBA. Call &lt;code&gt;.convert("RGB")&lt;/code&gt; before saving a JPEG.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch convert a folder of PSDs to PNG
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psd_tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;

&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;psds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iterdir&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.psd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.psb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;psd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ignore_preview&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# one bad file shouldn't stop the batch
&lt;/span&gt;        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it on a folder holding the sample and a copy I cut off after 200 KB on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ok   poster.psd (880, 1216)
fail truncated.psd OSError
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Hide a layer and render again
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psd_tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;

&lt;span class="n"&gt;psd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poster.psd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;descendants&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Poster text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# Option 1: filter at render time; the document is left alone
&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layer_filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_visible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filtered:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;| edited:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_updated&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# Option 2: hide the layer, which marks the document as edited
&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hidden  : edited:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_updated&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poster-no-text.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;filtered: RGBA (880, 1216) | edited: False
hidden  : edited: True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;One detail: &lt;code&gt;visible&lt;/code&gt; is the layer's own flag, while &lt;code&gt;is_visible()&lt;/code&gt; also checks whether the parent group is visible. Filter on &lt;code&gt;is_visible()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crop to a layer's bbox and make thumbnails
&lt;/h2&gt;

&lt;p&gt;The bbox is also a ready-made crop box for the rendered page. That gives you three different images of the same layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ignore_preview&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;plant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;alone&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                       &lt;span class="c1"&gt;# the layer by itself, trimmed
&lt;/span&gt;&lt;span class="n"&gt;placed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewbox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# the layer by itself, full canvas
&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;# the finished page, cut to that box
&lt;/span&gt;
&lt;span class="n"&gt;thumb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alone  RGBA (624, 665)
placed RGBA (880, 1216)
region RGBA (624, 665)
thumb  RGBA (185, 256)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  How psd-tools handles groups, smart objects, text and adjustment layers
&lt;/h2&gt;

&lt;p&gt;In psd-tools, &lt;code&gt;layer.kind&lt;/code&gt; is the class name in lowercase with "layer" removed. That's the rule in &lt;code&gt;psd_tools/api/layers.py&lt;/code&gt; 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:&lt;/p&gt;

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

&lt;p&gt;To see groups for real, I wrapped the two artwork layers in a group in memory (the file on disk isn't changed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layer_list&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Artwork&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top level  :&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;descendants:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;psd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;descendants&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;group:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has_pixels&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;top level  : ['Background', 'Artwork']
descendants: ['Background', 'Artwork', 'Monstera potted plant illustration', 'Poster text and badge elements']
group: group (110, 0, 879, 1139) False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Reading PSDs uploaded by users
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psd_tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ParseLimits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;

&lt;span class="n"&gt;psd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PSDImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poster.psd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parse_limits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ParseLimits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_read_bytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="n"&gt;max_total_bytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;max_alloc_bytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;a href="https://psd-tools.readthedocs.io/en/latest/untrusted.html" rel="noopener noreferrer"&gt;Handling untrusted files&lt;/a&gt; page lists every setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if you only have a flattened PNG?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;descendants()&lt;/code&gt; to return. You can cut the elements out by hand in an editor. Or you can run the flat file through an &lt;a href="https://intolayers.com/" rel="noopener noreferrer"&gt;image to layers&lt;/a&gt; converter first, then point the scripts above at the PSD it gives you.&lt;/p&gt;

&lt;p&gt;One option is &lt;a href="https://intolayers.com/" rel="noopener noreferrer"&gt;IntoLayers&lt;/a&gt;, 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Input is one PNG or JPEG per run, up to 10 MB.&lt;/li&gt;
&lt;li&gt;You choose 2, 3, 5, 7 or 9 layers, or Auto (2–17). The background counts as one.&lt;/li&gt;
&lt;li&gt;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 &lt;code&gt;kind == 'smartobject'&lt;/code&gt; with no &lt;code&gt;.text&lt;/code&gt; attribute.&lt;/li&gt;
&lt;li&gt;Anything that was hidden behind another element is a guess, not a recovery.&lt;/li&gt;
&lt;li&gt;Your first conversion (1K, 2 layers) runs without an account.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Other ways to read PSD layers
&lt;/h2&gt;

&lt;p&gt;psd-tools is the usual pure-Python choice, but it isn't the only one:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can psd-tools edit text in a PSD?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my psd.composite() output black or different from Photoshop?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;psd.composite()&lt;/code&gt; 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 &lt;code&gt;ignore_preview=True&lt;/code&gt; to render from the layers. Remaining differences usually come from layer effects psd-tools doesn't draw.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can psd-tools open PSB (large document) files?
&lt;/h3&gt;

&lt;p&gt;Yes. The 1.26.0 source describes &lt;code&gt;PSDImage&lt;/code&gt; as a "Photoshop PSD/PSB document", and &lt;code&gt;psd.version&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does psd-tools need Photoshop installed?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I convert a PSD to PNG in Python?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;PSDImage.open("file.psd").composite(ignore_preview=True).save("file.png")&lt;/code&gt;. For one PNG per layer, use the export loop above.&lt;/p&gt;

&lt;p&gt;Disclosure: I work on IntoLayers.&lt;/p&gt;

</description>
      <category>python</category>
      <category>tutorial</category>
      <category>imageprocessing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Draw a LaTeX Syntax Tree with the forest Package</title>
      <dc:creator>libersand</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:08:13 +0000</pubDate>
      <link>https://dev.to/libersand/how-to-draw-a-latex-syntax-tree-with-the-forest-package-4940</link>
      <guid>https://dev.to/libersand/how-to-draw-a-latex-syntax-tree-with-the-forest-package-4940</guid>
      <description>&lt;p&gt;To draw a LaTeX syntax tree, load &lt;code&gt;\usepackage[linguistics]{forest}&lt;/code&gt;, write the tree as labeled brackets inside a &lt;code&gt;forest&lt;/code&gt; environment, give every word its own brackets, add &lt;code&gt;roof&lt;/code&gt; for triangles, then name two nodes and join them with &lt;code&gt;\draw&lt;/code&gt; for movement arrows. Compile with pdflatex.&lt;/p&gt;

&lt;p&gt;This guide is for syntax students who hand in homework or a thesis typed in LaTeX. One sentence runs through every step: the statement &lt;em&gt;The students will read the paper&lt;/em&gt; and its question form &lt;em&gt;Which paper will the students read?&lt;/em&gt; Every code block below was compiled with pdflatex (TeX Live 2026, forest 2.1.5) on the free &lt;a href="https://texlive.net/" rel="noopener noreferrer"&gt;TeXLive.net&lt;/a&gt; online compiler on 25 October 2026. The broken examples in Step 7 were compiled too, so the error messages are copied from real logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Load forest with the linguistics library
&lt;/h2&gt;

&lt;p&gt;Put one line in your preamble: &lt;code&gt;\usepackage[linguistics]{forest}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The forest package is a TikZ-based package for drawing linguistic trees, written by Sašo Živanović and distributed on &lt;a href="https://ctan.org/pkg/forest" rel="noopener noreferrer"&gt;CTAN&lt;/a&gt; under the LaTeX Project Public License. The &lt;code&gt;linguistics&lt;/code&gt; option loads a small library that changes three defaults, per the &lt;a href="https://mirrors.ctan.org/graphics/pgf/contrib/forest/forest-doc.pdf" rel="noopener noreferrer"&gt;forest manual&lt;/a&gt; (section 4.1). Branches meet under the parent node (&lt;code&gt;sn edges&lt;/code&gt;), the top of the tree lines up with your example number, and node labels can run over several lines. The same library defines &lt;code&gt;roof&lt;/code&gt;, which you need in Step 5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt; a minimal document that compiles on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tex"&gt;&lt;code&gt;&lt;span class="k"&gt;\documentclass&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;article&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;\usepackage&lt;/span&gt;&lt;span class="na"&gt;[linguistics]&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;forest&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;\begin{document}&lt;/span&gt;
&lt;span class="nt"&gt;\begin{forest}&lt;/span&gt;
[TP [DP] [T&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt; [T] [VP]]]
&lt;span class="nt"&gt;\end{forest}&lt;/span&gt;
&lt;span class="nt"&gt;\end{document}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No TeX installed? Overleaf and TeXLive.net compile the same file in the browser. &lt;code&gt;\documentclass[border=8pt]{standalone}&lt;/code&gt; crops the PDF to the tree, which is how the images in this guide were made. Convert that PDF to PNG to put the tree into Word or Google Slides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; you get a small tree with TP on top and DP, T′ under it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Get a labeled bracket string for your sentence
&lt;/h2&gt;

&lt;p&gt;Before any LaTeX, you need the analysis written as labeled brackets: &lt;code&gt;[TP [DP the students] [T' [T will] [VP [V read] [DP the paper]]]]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The forest package only typesets the syntax tree you give it. It does not parse English. Getting the brackets right is the syntax part of the homework. Turning them into a picture is the typing part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt; there are three common ways to get the string.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write it by hand.&lt;/strong&gt; Draw the tree on paper first, then type it top-down, one bracket per node. This is the only route that shows your own analysis, so it is the one most instructors expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a free bracket previewer.&lt;/strong&gt; Several web pages turn a bracket string into a tree picture as you type. They are good for catching a misplaced bracket, but you still write the brackets yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start from a plain sentence.&lt;/strong&gt; Some tools take the sentence, write the analysis, and draw it. &lt;a href="https://vizcept.com/" rel="noopener noreferrer"&gt;Vizcept&lt;/a&gt;, a syntax tree generator, works this way: you type the sentence, pick Traditional, X-bar or Minimalist labels, and the tree is typeset from code. Paid plans can copy the labeled bracket string or forest code. If you already have a labeled bracket string, paste it instead and the tree is drawn exactly as written. Plans start at $14.90 a month, and you can try the first tree free as a watermarked PNG. The copied forest code leaves out movement arrows and there is no .tex file export, so you still add arrows yourself with &lt;code&gt;\draw&lt;/code&gt; (Step 6).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyc5juzvemgof0ds8hfo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feyc5juzvemgof0ds8hfo.png" alt="Syntax tree generator input box with the sentence " width="800" height="434"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Starting from a plain sentence instead of brackets. Screenshot of Vizcept's tool page, October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; count the brackets. Every &lt;code&gt;[&lt;/code&gt; needs a matching &lt;code&gt;]&lt;/code&gt;, and every node has exactly one label. Whatever produced the string, compare it with your own analysis before you hand it in. The tree is only as right as the analysis.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: Convert the bracket string into forest code
&lt;/h2&gt;

&lt;p&gt;Keep the labels and the nesting, and change two things: put every word string in its own brackets, and write primes in math mode.&lt;/p&gt;

&lt;p&gt;In a plain labeled bracket string, &lt;code&gt;[DP the students]&lt;/code&gt; means "a DP node with the words &lt;em&gt;the students&lt;/em&gt; under it". forest reads the same text as one node whose label is "DP the students". When we pasted the string from Step 2 into forest unchanged, it compiled without a single error and drew nodes labeled "DP the students" and "V read". No error message tells you something is wrong. A bare &lt;code&gt;T'&lt;/code&gt; prints a curly apostrophe (T’) instead of a prime (T′), so write &lt;code&gt;T$'$&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt; the bracket string from Step 2, converted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tex"&gt;&lt;code&gt;&lt;span class="nt"&gt;\begin{forest}&lt;/span&gt;
[TP
  [DP [the students]]
  [T&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
    [T [will]]
    [VP
      [V [read]]
      [DP [the paper]]]]]
&lt;span class="nt"&gt;\end{forest}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7bn1qhtq2tgujyo00gdt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7bn1qhtq2tgujyo00gdt.png" alt="A labeled bracket string and the equivalent forest code for " width="800" height="333"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same tree, two notations: forest needs the words in their own brackets and the prime written as T$'$. Diagram drawn for this guide.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; each word or word group sits at the bottom of its own branch. Spaces and line breaks between brackets do not matter, so indent one level per node. That makes a missing &lt;code&gt;]&lt;/code&gt; easy to spot. Blank lines are the exception (see Step 7).&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: Build the X-bar layers of the syntax tree
&lt;/h2&gt;

&lt;p&gt;In X-bar theory, every phrase XP has an intermediate X′ level between the phrase and its head X.&lt;/p&gt;

&lt;p&gt;X-bar theory builds every phrase from the same schema: a specifier, an X′ and a complement. The &lt;a href="https://mirrors.ctan.org/graphics/pgf/contrib/forest/forest-doc.pdf" rel="noopener noreferrer"&gt;forest manual&lt;/a&gt; opens its decoration section with exactly this XP, specifier, X′, complement template. Your instructor will check that each projection is there, even when it does not branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt; the same sentence with D′, N′ and V′ added. The first line puts every word on the same baseline. It uses &lt;code&gt;where n children=0{tier=word}{}&lt;/code&gt;, the manual's example (12), which assigns all nodes with no children to one tier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tex"&gt;&lt;code&gt;&lt;span class="nt"&gt;\begin{forest}&lt;/span&gt;
where n children=0&lt;span class="p"&gt;{&lt;/span&gt;tier=word&lt;span class="p"&gt;}{}&lt;/span&gt;
[TP
  [DP
    [D&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
      [D [the]]
      [NP [N&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt; [N [students]]]]]]
  [T&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
    [T [will]]
    [VP
      [V&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
        [V [read]]
        [DP
          [D&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
            [D [the]]
            [NP [N&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt; [N [paper]]]]]]]]]]
&lt;span class="nt"&gt;\end{forest}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhp3xji35g5c0t1wvbjux.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhp3xji35g5c0t1wvbjux.png" alt="Compiled X-bar syntax tree for " width="595" height="864"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Step 4 code as pdflatex renders it, with terminals aligned by tier=word. Compiled on TeXLive.net, October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; trace each head up to its phrase. N goes to N′ goes to NP, D goes to D′ goes to DP, V goes to V′ goes to VP. If one link is missing, a bracket is in the wrong place.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5: Add triangles to a syntax tree with roof
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;roof&lt;/code&gt; after the words of a phrase you do not want to analyze: &lt;code&gt;[DP [the students, roof]]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A full X-bar syntax tree gets tall fast. When a phrase's inside does not matter for the point you are making, linguists draw a triangle over its words. In forest the comma after the words starts the node's options, and &lt;code&gt;roof&lt;/code&gt; is an option from the linguistics library. The &lt;a href="https://mirrors.ctan.org/info/forest-quickstart/forest-quickstart.pdf" rel="noopener noreferrer"&gt;Forest Quickstart Guide for Linguists&lt;/a&gt; (section 5) shows the same pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt; both DPs as triangles, so the tree only shows what matters for the clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tex"&gt;&lt;code&gt;&lt;span class="nt"&gt;\begin{forest}&lt;/span&gt;
[TP
  [DP [the students, roof]]
  [T&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
    [T [will]]
    [VP
      [V&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
        [V [read]]
        [DP [the paper, roof]]]]]]
&lt;span class="nt"&gt;\end{forest}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; the triangle spans the words, and its top point touches the DP label. A long phrase makes a wide triangle. The quickstart guide's fix is a line break inside the label, as in &lt;code&gt;[walked \\ the dog, roof]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Add traces and movement arrows to a syntax tree
&lt;/h2&gt;

&lt;p&gt;Give the moved phrase and its trace a &lt;code&gt;name&lt;/code&gt;, then draw an arrow between the two names with TikZ after the tree's last bracket.&lt;/p&gt;

&lt;p&gt;The forest package has no built-in movement command. Instead, any node can get a TikZ name, and the &lt;code&gt;\draw&lt;/code&gt; command can connect two names. In &lt;em&gt;Which paper will the students read?&lt;/em&gt; two things move. The wh-phrase goes from object position to Spec-CP, and &lt;em&gt;will&lt;/em&gt; goes from T to C.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqgjkzpap7q9gh2p77nva.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqgjkzpap7q9gh2p77nva.png" alt="Page 8 of the forest package manual showing a CP tree with a dotted movement arrow from an object DP to Spec-CP, next to the forest code with name options and a \draw command" width="800" height="770"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The forest manual's movement example: name two nodes, then \draw between them. Source: &lt;a href="https://mirrors.ctan.org/graphics/pgf/contrib/forest/forest-doc.pdf" rel="noopener noreferrer"&gt;forest documentation&lt;/a&gt;, p. 8, by Sašo Živanović, LaTeX Project Public License 1.3.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt; traces are written as &lt;code&gt;$t_i$&lt;/code&gt; and &lt;code&gt;$t_j$&lt;/code&gt;, with matching subscripts on the moved items:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tex"&gt;&lt;code&gt;&lt;span class="nt"&gt;\begin{forest}&lt;/span&gt;
[CP
  [DP [which paper&lt;span class="p"&gt;$_&lt;/span&gt;&lt;span class="nb"&gt;i&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;, roof, name=wh]]
  [C&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
    [C [will&lt;span class="p"&gt;$_&lt;/span&gt;&lt;span class="nb"&gt;j&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;, name=aux]]
    [TP
      [DP [the students, roof]]
      [T&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
        [T [&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;t&lt;/span&gt;&lt;span class="p"&gt;_&lt;/span&gt;&lt;span class="nb"&gt;j&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;, name=auxtrace]]
        [VP
          [V&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;'&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;
            [V [read]]
            [DP [&lt;span class="p"&gt;$&lt;/span&gt;&lt;span class="nb"&gt;t&lt;/span&gt;&lt;span class="p"&gt;_&lt;/span&gt;&lt;span class="nb"&gt;i&lt;/span&gt;&lt;span class="p"&gt;$&lt;/span&gt;, name=whtrace]]]]]]]]
&lt;span class="k"&gt;\draw&lt;/span&gt;&lt;span class="na"&gt;[-&amp;gt;]&lt;/span&gt; (whtrace) to[out=south west, in=south] (wh);
&lt;span class="k"&gt;\draw&lt;/span&gt;&lt;span class="na"&gt;[-&amp;gt;]&lt;/span&gt; (auxtrace) to[out=south west, in=south] (aux);
&lt;span class="nt"&gt;\end{forest}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5cjcqn9um9m41u4tsdp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5cjcqn9um9m41u4tsdp.png" alt="Compiled CP tree for " width="602" height="859"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Step 6 code compiled: wh-movement to Spec-CP and T-to-C movement, drawn with \draw. Compiled on TeXLive.net, October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; each arrow starts at a trace and ends under the phrase that moved. &lt;code&gt;out=&lt;/code&gt; and &lt;code&gt;in=&lt;/code&gt; set the angle the curve leaves and enters. In the compiled tree the &lt;em&gt;will&lt;/em&gt; arrow crosses the subject triangle; changing the &lt;code&gt;out=&lt;/code&gt; and &lt;code&gt;in=&lt;/code&gt; angles moves the curve if you want it clear. Some textbooks strike through the lower copy instead of writing &lt;em&gt;t&lt;/em&gt;. &lt;code&gt;\sout{the paper}&lt;/code&gt; does that, and it needs &lt;code&gt;\usepackage[normalem]{ulem}&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Fix the compile errors you will hit first
&lt;/h2&gt;

&lt;p&gt;When forest fails, read the first line that starts with &lt;code&gt;!&lt;/code&gt; in the log. The table below lists the messages pdflatex printed for seven common mistakes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;Error message (first line)&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A blank line inside &lt;code&gt;forest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Paragraph ended before \forestOappto was complete.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete empty lines inside the environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A comma in a label, like &lt;code&gt;[NP, PP]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;I do not know the key '/tikz/PP'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wrap the label in braces: &lt;code&gt;[{NP, PP}]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;roof&lt;/code&gt; with plain &lt;code&gt;\usepackage{forest}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;I do not know the key '/tikz/roof'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Load &lt;code&gt;\usepackage[linguistics]{forest}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;\draw&lt;/code&gt; without the final &lt;code&gt;;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Giving up on this path. Did you forget a semicolon?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;End every &lt;code&gt;\draw&lt;/code&gt; with &lt;code&gt;;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;\draw&lt;/code&gt; placed after a node without braces&lt;/td&gt;
&lt;td&gt;Same semicolon message&lt;/td&gt;
&lt;td&gt;Wrap code after a node in &lt;code&gt;{ }&lt;/code&gt;, or move it after the last &lt;code&gt;]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One &lt;code&gt;]&lt;/code&gt; missing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Argument of \bracket@Parse@token has an extra }.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Count brackets, or indent one level per node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;\sout&lt;/code&gt; without ulem&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Undefined control sequence.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;\usepackage[normalem]{ulem}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of these are easy to misread. The comma error names &lt;code&gt;PP&lt;/code&gt; as an unknown &lt;em&gt;key&lt;/em&gt; because forest reads everything after a comma as options. The unbraced &lt;code&gt;\draw&lt;/code&gt; reports a missing semicolon even when the semicolon is there. The manual's warning is that TikZ code after a node "should usually be enclosed in braces to hide it from the bracket parser".&lt;/p&gt;

&lt;h2&gt;
  
  
  Is forest better than qtree or tikz-qtree for syntax trees?
&lt;/h2&gt;

&lt;p&gt;For most syntax homework, forest is the safer choice, but qtree is closer to the bracket notation you already know. &lt;a href="https://ctan.org/pkg/qtree" rel="noopener noreferrer"&gt;qtree&lt;/a&gt; (version 3.1b, 2008) writes a node as &lt;code&gt;[.VP ... ]&lt;/code&gt;, with a dot before each label and words separated by spaces. That is almost the labeled bracket string unchanged. Its manual limits trees to 20 levels and five branches per node. &lt;a href="https://ctan.org/pkg/tikz-qtree" rel="noopener noreferrer"&gt;tikz-qtree&lt;/a&gt; keeps the qtree syntax and draws with TikZ, which adds arrows between nodes. forest uses its own bracket syntax, has a packing algorithm its manual describes as producing very compact trees, and has the linguistics library with &lt;code&gt;roof&lt;/code&gt; and c-command helpers. If a classmate's template uses &lt;code&gt;\Tree [.S ...]&lt;/code&gt;, that is qtree or tikz-qtree, and it will not paste into a &lt;code&gt;forest&lt;/code&gt; environment as is.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I create a tree in LaTeX?
&lt;/h3&gt;

&lt;p&gt;Load &lt;code&gt;\usepackage[linguistics]{forest}&lt;/code&gt;, then write the tree inside &lt;code&gt;\begin{forest} ... \end{forest}&lt;/code&gt; with one pair of square brackets per node, label first. Words go in their own brackets under their category. Compile with pdflatex. Overleaf has a ready-made &lt;a href="https://www.overleaf.com/latex/templates/linguistics-syntax-tree-drawing-template-using-forest/xmvmdwttqbnn" rel="noopener noreferrer"&gt;forest syntax tree template&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you do a syntax tree?
&lt;/h3&gt;

&lt;p&gt;Find the main verb and its arguments, group words into phrases around their heads (NP, VP, PP), then attach the phrases to a clause node (S, TP or CP) from the bottom up. Write the result as labeled brackets and check that every word appears once, left to right, along the bottom.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do my nodes show "DP the students" instead of a branch?
&lt;/h3&gt;

&lt;p&gt;The forest package treats everything before the first nested bracket as one label, so &lt;code&gt;[DP the students]&lt;/code&gt; becomes a single node. Put the words in their own brackets, &lt;code&gt;[DP [the students]]&lt;/code&gt;, and the DP gets a branch down to the words.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: written by the team behind Vizcept.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>latex</category>
      <category>tutorial</category>
      <category>linguistics</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to Schedule AzuraCast Jingles and Station IDs</title>
      <dc:creator>libersand</dc:creator>
      <pubDate>Sat, 24 Oct 2026 06:35:15 +0000</pubDate>
      <link>https://dev.to/libersand/how-to-schedule-azuracast-jingles-and-station-ids-obh</link>
      <guid>https://dev.to/libersand/how-to-schedule-azuracast-jingles-and-station-ids-obh</guid>
      <description>&lt;p&gt;To schedule AzuraCast jingles, give each kind of cut its own playlist. Set short IDs to "Once per x Songs", shuffled, and the top-of-hour ID to "Once per Hour" at minute 0. Tick "Hide Metadata from Listeners ('Jingle Mode')" and leave the Advanced tab's scheduling boxes off.&lt;/p&gt;

&lt;p&gt;The rest of this guide explains why each setting matters, using one real support thread as the running example. In &lt;a href="https://github.com/AzuraCast/AzuraCast/discussions/7042" rel="noopener noreferrer"&gt;GitHub discussion #7042&lt;/a&gt;, a broadcaster had a jingles playlist scheduled from 5 a.m. to midnight, set to play once every 4 songs, and it never played. The accepted answer pointed to a single checkbox. You'll see which one in Step 4.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxk05c2y0qgk68urbfxxt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxk05c2y0qgk68urbfxxt.png" alt="GitHub discussion 7042 where a jingles playlist set to once every 4 songs never plays, with a reply saying to uncheck Only play one track at scheduled time" width="800" height="750"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The support thread used throughout this guide. Source: &lt;a href="https://github.com/AzuraCast/AzuraCast/discussions/7042" rel="noopener noreferrer"&gt;AzuraCast discussion #7042&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;p&gt;You need three things in place before you set up AzuraCast jingles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A running AzuraCast station. The &lt;a href="https://www.azuracast.com/docs/getting-started/installation/docker/" rel="noopener noreferrer"&gt;Docker install&lt;/a&gt; is the standard route, and any user role with the "Manage Station Media" permission can edit playlists.&lt;/li&gt;
&lt;li&gt;At least one General Rotation playlist with music in it, so the AutoDJ always has something to fall back on.&lt;/li&gt;
&lt;li&gt;Your finished jingle files: MP3 or WAV, already mixed, one file per cut.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Labels and behavior below were checked against AzuraCast 0.23.9 and the rolling release on the public demo on 2026-10-24. The examples use a made-up station, Copper Pines FM.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: Make the station ID audio first
&lt;/h2&gt;

&lt;p&gt;Finish and tag every cut before you open AzuraCast. The scheduler can't fix a quiet ID or a missing name.&lt;/p&gt;

&lt;p&gt;There are three common ways to get the audio:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Record it yourself.&lt;/strong&gt; Free, and it sounds like you. Room noise and level matching are your problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hire a voice artist or an imaging company.&lt;/strong&gt; A human voice, and singing if you want it. Expect to wait for delivery and pay per package.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use an AI voice tool.&lt;/strong&gt; Generic text-to-speech gives you a bare voice that you still mix with music yourself. Radio-specific tools mix it for you. One example is &lt;a href="https://jinglelark.com/" rel="noopener noreferrer"&gt;JingleLark&lt;/a&gt;, which we build: type the script, pick one of 24 radio voices, and it adds an intro, a background bed and an outro, normalized to −16 LUFS, as an MP3 or WAV. Packs start at $14.90 for 50 credits, paid once with no subscription, and a voiceover of up to 220 characters costs 1 credit. Mixing and downloads are free. It makes voiced IDs only, with no singing, and all 24 voices are native English speakers, so scripts in other languages come out with an accent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffnihpeuwodctuquo46ew.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffnihpeuwodctuquo46ew.png" alt="JingleLark radio station ID maker with a script for Oakhollow Oldies, a radio DJ voice selected and intro, background and outro slots for building the jingle" width="800" height="488"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One AI option: script, voice and mix in one screen. Screenshot of JingleLark, October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Whichever route you take, you want a set of files, not one. Three things decide how well they air:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Level.&lt;/strong&gt; AzuraCast's own post-processing setting says that "values between -14 and -18 LUFS are common for Internet radio stations" (&lt;a href="https://github.com/AzuraCast/AzuraCast/blob/0.23.9/frontend/components/Admin/Stations/Form/BackendForm.vue#L193" rel="noopener noreferrer"&gt;source&lt;/a&gt;). An ID inside that range won't jump out over the music or sink under it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags.&lt;/strong&gt; AzuraCast reads Title, Artist and Album from the file's tags when you upload (&lt;a href="https://github.com/AzuraCast/AzuraCast/blob/0.23.9/backend/src/Entity/StationMedia.php#L170" rel="noopener noreferrer"&gt;source&lt;/a&gt;). Set Artist to &lt;code&gt;Copper Pines FM&lt;/code&gt; and give each file a Title that says what it is, such as &lt;code&gt;ID - Top of Hour 01&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File names.&lt;/strong&gt; Use a prefix per element (&lt;code&gt;toh-&lt;/code&gt;, &lt;code&gt;id-&lt;/code&gt;, &lt;code&gt;sweep-&lt;/code&gt;), so a folder sort groups them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever route you use to &lt;a href="https://jinglelark.com/radio-station-id-maker" rel="noopener noreferrer"&gt;make a station ID for your radio station&lt;/a&gt;, make several short versions, so a once-every-4-songs rotation doesn't repeat the same cut every 15 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; play each file next to a song from your library at the same volume. None of them should sound much louder or quieter.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: Create one AzuraCast playlist per element
&lt;/h2&gt;

&lt;p&gt;An AzuraCast playlist carries one rule, so each element type needs its own playlist.&lt;/p&gt;

&lt;p&gt;For Copper Pines FM that means three playlists:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Playlist&lt;/th&gt;
&lt;th&gt;Contents&lt;/th&gt;
&lt;th&gt;Rule it needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IDs - Top of Hour&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hourly ID with the full station name and city&lt;/td&gt;
&lt;td&gt;once per hour, at a set minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IDs - Short&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3–5 second name IDs&lt;/td&gt;
&lt;td&gt;every few songs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Sweepers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Short cuts that bridge two songs&lt;/td&gt;
&lt;td&gt;every few songs, at a different interval&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Upload the files under &lt;strong&gt;Media → Music Files&lt;/strong&gt;, then add each folder to its playlist. The playlist list has an "Apply to Folders" action for this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; each playlist holds one element type only.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: Pick the Playlist Type and turn on Jingle Mode
&lt;/h2&gt;

&lt;p&gt;The Playlist Type sets how often a playlist plays. On the Basic Info tab, choose "Song-Based Playlist" as the Source. Then set these fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field (exact UI label)&lt;/th&gt;
&lt;th&gt;Top of Hour&lt;/th&gt;
&lt;th&gt;Short IDs&lt;/th&gt;
&lt;th&gt;Sweepers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Playlist Type&lt;/td&gt;
&lt;td&gt;Once per Hour&lt;/td&gt;
&lt;td&gt;Once per x Songs&lt;/td&gt;
&lt;td&gt;Once per x Songs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minute of Hour to Play / Number of Songs Between Plays&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Song Playback Order&lt;/td&gt;
&lt;td&gt;Shuffled&lt;/td&gt;
&lt;td&gt;Shuffled&lt;/td&gt;
&lt;td&gt;Shuffled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hide Metadata from Listeners ("Jingle Mode")&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Allow Requests from This Playlist&lt;/td&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fezpoid3uvqnolktszmxg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fezpoid3uvqnolktszmxg.png" alt="AzuraCast playlist Basic Info form with Hide Metadata from Listeners Jingle Mode enabled, Playlist Type set to Once per x Songs and Shuffled playback order" width="800" height="632"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Short-ID playlist settings on the Basic Info tab. Screenshot from the AzuraCast public demo, October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Once per x Minutes" is the third option. It counts time instead of songs, which suits stations that mix hour-long DJ sets with three-minute tracks. The numbers above are starting points.&lt;/p&gt;

&lt;p&gt;Jingle Mode does more than its label says. In AzuraCast 0.23.9, a track from a Jingle Mode playlist carries a &lt;code&gt;jingle_mode&lt;/code&gt; annotation, and the Liquidsoap script sends the previous track's metadata again instead of the jingle's (&lt;a href="https://github.com/AzuraCast/AzuraCast/blob/0.23.9/util/docker/stations/liquidsoap/azuracast.liq#L678" rel="noopener noreferrer"&gt;source&lt;/a&gt;). Listeners keep seeing the song that just ended while your ID plays. AzuraCast also marks those plays as not visible in song history (&lt;a href="https://github.com/AzuraCast/AzuraCast/blob/0.23.9/backend/src/Entity/SongHistory.php#L161" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; reopen each playlist and confirm Jingle Mode is still on and requests are off.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: Schedule the playlists and leave the Advanced boxes off
&lt;/h2&gt;

&lt;p&gt;The Schedule tab sets when a playlist is active, and the Playlist Type sets how often it plays inside that window. With nothing scheduled, AzuraCast says the playlist "will play at all times". To limit it, click &lt;strong&gt;Add Schedule Item&lt;/strong&gt; and set a Start Time and End Time, plus optional dates and days of the week.&lt;/p&gt;

&lt;p&gt;In #7042, the accepted answer flagged one box on the Advanced tab, under "Advanced Manual AutoDJ Scheduling Options": "Only play one track at scheduled time." That reply explained that this option plays one jingle in the whole 5 a.m. to midnight window and then stops. The suggested setup was Once per x Songs at 4, Shuffled, Jingle Mode on, a schedule of 05:00 to 11:59 PM, and no advanced options. The broadcaster reported back that it worked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmppjqod36z7jfmv1lawg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmppjqod36z7jfmv1lawg.png" alt="AzuraCast playlist Advanced tab listing Interrupt other songs to play at scheduled time, Only play one track at scheduled time and Merge playlist to play as a single track, all unticked" width="800" height="241"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Leave these three boxes off for ID and sweeper playlists. Screenshot from the AzuraCast public demo, October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The 0.23.9 source agrees with that reply. With single-track on, a playlist that has already played since the start of its current schedule block isn't eligible again (&lt;a href="https://github.com/AzuraCast/AzuraCast/blob/0.23.9/backend/src/Radio/AutoDJ/Scheduler.php#L341" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;"Interrupt other songs to play at scheduled time" is the other box to leave alone for IDs. When the broadcaster in #7042 tried it, jingles sometimes cut in on top of a song and most of the time didn't play at all.&lt;/p&gt;

&lt;p&gt;When two playlists are due at the same time, priority decides which one plays. AzuraCast's &lt;a href="https://www.azuracast.com/docs/user-guide/playlists/" rel="noopener noreferrer"&gt;playlist docs&lt;/a&gt; list the defaults: Once per Hour beats Once per x Songs, which beats Once per x Minutes, which beats General Rotation, and a scheduled playlist beats an unscheduled one of the same type. Song requests always come first. The queue builder in 0.23.9 checks them in that order (&lt;a href="https://github.com/AzuraCast/AzuraCast/blob/0.23.9/backend/src/Radio/AutoDJ/QueueBuilder.php#L104" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wdxkzcfd3iqejn434nx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wdxkzcfd3iqejn434nx.png" alt="AzuraCast documentation table of default playlist priorities, with song requests first and Once Per X Hour scheduled playlists at priority 7" width="685" height="820"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AzuraCast's Default Priorities table: hourly beats every-x-songs, which beats every-x-minutes and general rotation. Source: &lt;a href="https://www.azuracast.com/docs/user-guide/playlists/" rel="noopener noreferrer"&gt;AzuraCast docs&lt;/a&gt; (MIT).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; for each jingle playlist, the Advanced tab has no boxes ticked.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5: Listen to the live stream for an hour
&lt;/h2&gt;

&lt;p&gt;Listen to the live stream across one top of the hour. Before that, open &lt;strong&gt;Broadcasting → Upcoming Song Queue&lt;/strong&gt; to see what the AutoDJ has lined up and which playlist each item came from.&lt;/p&gt;

&lt;p&gt;Expect the hourly ID a little after :00, not exactly on it. The AutoDJ waits for the current song to end, so the ID airs when that song finishes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6eopir5v8lsx7ij0pu1p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6eopir5v8lsx7ij0pu1p.png" alt="Timeline comparing an hourly ID that airs at 12:00:47 after the current song ends with one that cuts the song at 12:00:00 when Interrupt other songs is on" width="800" height="269"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Why the top-of-hour ID lands after :00. Illustration, not a measurement.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The 0.23.9 scheduler adds two limits that few guides mention (&lt;a href="https://github.com/AzuraCast/AzuraCast/blob/0.23.9/backend/src/Radio/AutoDJ/Scheduler.php#L150" rel="noopener noreferrer"&gt;source&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Once per Hour playlist is only eligible from its target minute until 15 minutes after it. If a long track is still playing at :15, that hour's ID is skipped.&lt;/li&gt;
&lt;li&gt;It won't fire again within 30 minutes of its last play.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To air an ID at both :00 and :30, create two Once per Hour playlists, one at minute 0 and one at minute 30. Another user suggested the same pattern (one playlist per slot) in &lt;a href="https://github.com/AzuraCast/AzuraCast/discussions/8286" rel="noopener noreferrer"&gt;discussion #8286&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check:&lt;/strong&gt; note when the hourly ID aired and count songs between short IDs against your settings.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why isn't my AzuraCast jingle playing?
&lt;/h2&gt;

&lt;p&gt;When AzuraCast jingles never play, it usually comes down to one of five settings. Check them in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"Only play one track at scheduled time" is ticked.&lt;/strong&gt; You get one jingle per schedule block. Untick it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A higher-priority playlist always wins.&lt;/strong&gt; For example, a Once per Hour playlist with a wide window, or a scheduled playlist that blocks an unscheduled one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The playlist is outside its schedule, or disabled.&lt;/strong&gt; Check Start Time, End Time and the days of the week. An end time earlier than the start time means the playlist runs overnight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A long track runs past the 15-minute window.&lt;/strong&gt; With hour-long mixes, Once per Hour can miss. Once per x Minutes is more forgiving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changes haven't taken effect.&lt;/strong&gt; In #7042, the answerer said plain playlist edits apply live unless the station uses Manual AutoDJ Mode. A &lt;a href="https://shoutcastsolutions.com/knowledgebase.php?action=displayarticle&amp;amp;id=24" rel="noopener noreferrer"&gt;hosting provider's knowledge base&lt;/a&gt; says playlists with advanced options need &lt;strong&gt;Restart Broadcasting&lt;/strong&gt;, which drops listeners.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a queue item looks wrong, open its logs from the Upcoming Song Queue. The log shows why the AutoDJ picked it.&lt;/p&gt;
&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;
&lt;h3&gt;
  
  
  What is a station ID on an AzuraCast station?
&lt;/h3&gt;

&lt;p&gt;A station ID is a short audio cut that says your station's name. In AzuraCast it's an ordinary media file in a playlist set to Jingle Mode, and the AutoDJ slots it between songs according to the Playlist Type.&lt;/p&gt;
&lt;h3&gt;
  
  
  Can AzuraCast play a jingle over the start of a song?
&lt;/h3&gt;

&lt;p&gt;Not out of the box. AzuraCast plays jingles between tracks. Playing a jingle over a song is an open request on the AzuraCast suggestion board (&lt;a href="https://features.azuracast.com/suggestions/551367/jingle-while-a-song-is-played" rel="noopener noreferrer"&gt;Jingle while a song is played&lt;/a&gt;). Power users can write their own Liquidsoap rules with an "Advanced" Playlist Type, which AzuraCast leaves for you to configure by hand.&lt;/p&gt;
&lt;h3&gt;
  
  
  Can I force a jingle to play right next to another cut?
&lt;/h3&gt;

&lt;p&gt;Yes, with an Advanced playlist and a Liquidsoap rule, added under &lt;strong&gt;Broadcasting → Edit Liquidsoap Configuration&lt;/strong&gt;. The AzuraCast docs give this example for two playlists that should always play back to back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;radio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;playlist_commercials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;playlist_bumpers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;radio&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AzuraCast names each playlist's variable &lt;code&gt;playlist_&lt;/code&gt; plus the name in lowercase, with spaces turned into underscores.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: written by the team behind JingleLark.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>tutorial</category>
      <category>opensource</category>
      <category>audio</category>
    </item>
    <item>
      <title>IntoLayers is live: split any image into editable layers with AI</title>
      <dc:creator>libersand</dc:creator>
      <pubDate>Mon, 14 Sep 2026 14:44:44 +0000</pubDate>
      <link>https://dev.to/libersand/intolayers-is-live-split-any-image-into-editable-layers-with-ai-2e4a</link>
      <guid>https://dev.to/libersand/intolayers-is-live-split-any-image-into-editable-layers-with-ai-2e4a</guid>
      <description>&lt;p&gt;I've spent the last few weeks building &lt;strong&gt;IntoLayers&lt;/strong&gt;, an AI &lt;a href="https://intolayers.com" rel="noopener noreferrer"&gt;image to layers&lt;/a&gt; converter, and it's live today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;You have a finished image — a poster, a flyer, a social graphic someone exported as a flat PNG — and you need to change &lt;em&gt;one thing&lt;/em&gt; in it. The text is wrong, the offer changed, the source file is gone. Normally that means masking things out by hand, or regenerating the whole image and hoping nothing else shifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Upload a PNG or JPEG, click Convert, and IntoLayers hands back the design as separate, named, transparent layers instead of one flat image — text, subjects, graphics and a reconstructed background, each on its own layer with the gaps filled in behind whatever it removed.&lt;/p&gt;

&lt;p&gt;One poster in, six layers out, as an example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Poster gradient background&lt;/li&gt;
&lt;li&gt;Terracotta flower pot&lt;/li&gt;
&lt;li&gt;Monstera plant foliage&lt;/li&gt;
&lt;li&gt;Spring sale subtitle&lt;/li&gt;
&lt;li&gt;GREEN HOUSE main title&lt;/li&gt;
&lt;li&gt;NEW badge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Download any layer on its own as a transparent PNG, or export the whole stack as a single layered PSD that opens straight into Photoshop, Photopea or Affinity Photo.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few details I cared about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's not background removal.&lt;/strong&gt; A background remover gives you one cutout and throws the rest away. This keeps every element as its own layer and rebuilds what was behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PSD packaging happens client-side.&lt;/strong&gt; The layers get assembled into the PSD file in your browser, so the image isn't re-uploaded anywhere just to zip the layers together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No account needed to try it.&lt;/strong&gt; One conversion at 1K is free; signing in unlocks 1.5K/2K output and keeps results in a library.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who it's actually for
&lt;/h2&gt;

&lt;p&gt;This started from a narrow set of situations that kept coming up: fixing one wrong element in an AI-generated image without regenerating the whole thing, updating an approved campaign visual after the offer changed, or picking up a flyer whose working file walked out the door with whoever made it.&lt;/p&gt;

&lt;p&gt;If any of that sounds familiar, I'd like the feedback: &lt;a href="https://intolayers.com" rel="noopener noreferrer"&gt;intolayers.com&lt;/a&gt;. No signup required for the first conversion.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to Stop an Image Model From Ignoring Your Reference Image</title>
      <dc:creator>libersand</dc:creator>
      <pubDate>Fri, 21 Aug 2026 14:16:12 +0000</pubDate>
      <link>https://dev.to/libersand/how-to-stop-an-image-model-from-ignoring-your-reference-image-i5a</link>
      <guid>https://dev.to/libersand/how-to-stop-an-image-model-from-ignoring-your-reference-image-i5a</guid>
      <description>&lt;p&gt;A user uploaded a floor plan, typed "remove the room labels", and got back the same plan — labels still on it. Tried again, same result.&lt;/p&gt;

&lt;p&gt;The interesting part: the model was not ignoring the instruction. Another line in our own prompt was quietly outranking it.&lt;/p&gt;

&lt;p&gt;I work on &lt;a href="https://floordrafter.com/" rel="noopener noreferrer"&gt;FloorDrafter&lt;/a&gt;, an AI floor plan generator. A big part of the product takes a reference image — a photo of a hand sketch, a scanned blueprint, an existing plan — and applies one requested change to it. That turns out to be a much harder prompt problem than generating from scratch, because now the model has two masters: the picture and the sentence.&lt;/p&gt;

&lt;p&gt;Here are six things we learned making it behave, plus how we measure whether a prompt change actually helped.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Separate "how to draw" from "what to draw"
&lt;/h2&gt;

&lt;p&gt;Our style presets started life as single strings:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;top-down 2D color floor plan: flat orthographic overhead view, color-filled rooms… &lt;strong&gt;clear room labels and dimension lines with measurements&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Looks harmless. But that string got injected into the same block as the user's instruction, under a rule that said everything in the block must be delivered fully and visibly. So the block read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REQUESTED CHANGE
- Representation: ...clear room labels and dimension lines...
- remove the room labels          &amp;lt;- the user's own words
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two contradictory requirements, both marked mandatory. The style description won, because it was more specific.&lt;/p&gt;

&lt;p&gt;The fix was to split every style into two halves: &lt;code&gt;render&lt;/code&gt; (viewpoint, line weight, materials, lighting — &lt;em&gt;how&lt;/em&gt; to draw) and &lt;code&gt;annotations&lt;/code&gt; (room labels, dimension lines — &lt;em&gt;what content&lt;/em&gt; to include). The from-scratch branch names both. The edit-an-existing-image branch names only &lt;code&gt;render&lt;/code&gt;, because labels and dimensions are content that already exists in the reference — whether they stay is the user's call, not the style preset's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generalizable version:&lt;/strong&gt; if your prompt is assembled from fragments, audit every fragment for whether it smuggles in content requirements. A fragment that describes &lt;em&gt;form&lt;/em&gt; is safe to inject anywhere. A fragment that describes &lt;em&gt;content&lt;/em&gt; can collide with user intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Write the rule as "only change what's named", not as a list of prohibitions
&lt;/h2&gt;

&lt;p&gt;The obvious way to protect a reference image is a MUST NOT list: don't move walls, don't resize rooms, don't change the window positions.&lt;/p&gt;

&lt;p&gt;That list locks the user out. Someone who wants to "keep the layout but knock down the kitchen wall" now can't, because you preemptively banned it.&lt;/p&gt;

&lt;p&gt;Inverting it works better: change only what the instruction names, leave everything else as it is in the reference. The granularity comes from the user's own sentence instead of from your list.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Explicitly authorize the requested change, or you get a copy
&lt;/h2&gt;

&lt;p&gt;This one is counterintuitive. After we tightened the fidelity language, output quality went up — and then users started reporting that nothing changed at all. The model had become so conservative it was returning something close to the original image.&lt;/p&gt;

&lt;p&gt;Fidelity instructions alone teach the model that changing things is risky. You have to also grant full permission to execute the named change, and add a closing self-check that confirms the change is actually visible. Both halves, or you trade one failure mode for the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Ban &lt;em&gt;adding&lt;/em&gt;, never ban &lt;em&gt;existing&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Our closing prohibition originally read, roughly, "no text beyond room labels and dimensions."&lt;/p&gt;

&lt;p&gt;Then a user uploaded a full drawing sheet — not a bare floor plan, but a title block, project name, legend, scale bar, north arrow, notes. The model read that prohibition literally and deleted all of it. A real case: a church site plan came back with the main title, the church name, the SIMBOLOGÍA legend, the scale bar, the NOTA block and five bottom notes all gone. The floor plan survived. Everything around it did not.&lt;/p&gt;

&lt;p&gt;A prohibition on &lt;em&gt;text&lt;/em&gt; cannot distinguish "text you invented" from "text that was already there". So the rule can only forbid &lt;strong&gt;adding&lt;/strong&gt;. Preserving what exists needs its own affirmative clause — and it needs one more sentence that most people miss:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Changing the style is not a reason to delete content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without that, picking a monochrome CAD style gets read as "so only keep room labels and dimensions", and you are back to deleting the sheet.&lt;/p&gt;

&lt;p&gt;Both clauses shipped, and both are asserted in the test suite described at the end — title blocks, legends, scale bars and note blocks are now on an explicit preserve list, copied verbatim.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Some constraints must be immune to your own exemption clauses
&lt;/h2&gt;

&lt;p&gt;Once you have a rule that says "only change what's named", you will naturally write self-checks that begin &lt;em&gt;"Setting aside what the change named…"&lt;/em&gt;. That phrasing is a loophole.&lt;/p&gt;

&lt;p&gt;A user photographed a C-shaped outline, 16 by 24, and wrote &lt;strong&gt;"2 units on 1 footprint"&lt;/strong&gt;. The model duplicated the entire C shape and butted the two copies together into a 32 by 24 building. Every dimension label was preserved perfectly. The geometry had doubled.&lt;/p&gt;

&lt;p&gt;Two failures stacked here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The protection clause banned &lt;em&gt;moving&lt;/em&gt; the footprint but not duplicating, scaling or extending it.&lt;/li&gt;
&lt;li&gt;The self-check started with the exemption phrase, so the model decided the footprint change fell under "what the change named" and skipped the check entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The footprint check had to be rewritten to start with &lt;strong&gt;"Regardless of what the change named…"&lt;/strong&gt;. Geometric integrity is not negotiable by the instruction; content is.&lt;/p&gt;

&lt;p&gt;There is a second lesson buried in that example. "2 units on 1 footprint" contains a goal (2 units) and a boundary (1 footprint) in one breath. Models reliably grab the goal and drop the boundary, so the prompt has to say out loud that the user's sentence may contain limits, not just targets.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A capability needs an exemption in every section that can override it
&lt;/h2&gt;

&lt;p&gt;Back to the labels. "Remove the labels" failed because three separate parts of the prompt each independently reinstated them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The rule authorized restyling and adding — but never mentioned deleting.&lt;/li&gt;
&lt;li&gt;The sheet-protection clause said do not drop, blank out, condense or summarise any existing element.&lt;/li&gt;
&lt;li&gt;The closing self-check saw missing annotations and told the model to redo them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Patch any one of those and the other two still win. Deletion had to be named in all three places at once — and scoped tightly ("delete only what was named; deleting anything else is an error"), because a broad deletion permission takes you straight back to lesson 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; when you add a capability to a long prompt, grep the whole thing for sections that could veto it. Long prompts develop internal politics.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we know any of this worked
&lt;/h2&gt;

&lt;p&gt;Prompt changes are easy to rationalize and hard to verify. We ran fixed probes: same reference image, same instruction, repeated runs per variant, counting outcomes by hand against the previous prompt. The denominators below count elements rather than runs — dimension-chain segments in the reference, room labels across the probe set, and images in the sweep.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original dimension chain survives&lt;/td&gt;
&lt;td&gt;~0.6 / 9&lt;/td&gt;
&lt;td&gt;~7 / 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Room labels preserved&lt;/td&gt;
&lt;td&gt;~5 / 20&lt;/td&gt;
&lt;td&gt;~15 / 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images with invented dimensions&lt;/td&gt;
&lt;td&gt;9 / 10&lt;/td&gt;
&lt;td&gt;0 / 10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those numbers are small-sample and hand-counted. That is fine — they are decision-grade, not paper-grade. The point is that "this prompt feels better" is not a finding, and a fixed probe costs an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unit-testing a prompt
&lt;/h2&gt;

&lt;p&gt;The six lessons above are all invariants: properties the assembled prompt must have, in every branch, forever. Nothing stops a future edit from quietly removing one — they are just strings in a template.&lt;/p&gt;

&lt;p&gt;So they are unit tests now. The prompt builder is a pure function, which makes this cheap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bans only added text, never existing text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildPrompt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hasReference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ADD_ONLY_TEXT_RULE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// "do not add … text"&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doesNotMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LEGACY_TEXT_BAN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the old blanket ban&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;states that a style change is not a reason to delete content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;makes the footprint check immune to the change exemption&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildPrompt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hasReference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FOOTPRINT_UNCONDITIONAL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "regardless of what the change named"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 28 of them. They do not test the model — they test that the string we send still contains the clauses we worked out. Each one encodes a specific failure mode we found and fixed, so it cannot come back unnoticed.&lt;/p&gt;

&lt;p&gt;If you maintain a prompt longer than a screenful, this is the highest-value hour you can spend on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Split prompt fragments into &lt;em&gt;form&lt;/em&gt; and &lt;em&gt;content&lt;/em&gt;; only form is safe to inject everywhere.&lt;/li&gt;
&lt;li&gt;Prefer "change only what is named" over a list of prohibitions.&lt;/li&gt;
&lt;li&gt;Grant the change explicitly, or fidelity language will suppress it.&lt;/li&gt;
&lt;li&gt;Forbid adding, never forbid existing.&lt;/li&gt;
&lt;li&gt;Make integrity constraints immune to your own exemption phrasing.&lt;/li&gt;
&lt;li&gt;New capabilities need an exemption in every section that could veto them.&lt;/li&gt;
&lt;li&gt;Probe with fixed inputs and count. Then lock the result in with tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is specific to floor plans. Any time you send an image plus an instruction and expect the untouched parts to stay untouched, the same failure modes are waiting.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>promptengineering</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Postgres driver silently shifted my timestamps by 8 hours — and every test stayed green</title>
      <dc:creator>libersand</dc:creator>
      <pubDate>Mon, 17 Aug 2026 14:14:55 +0000</pubDate>
      <link>https://dev.to/libersand/a-postgres-driver-silently-shifted-my-timestamps-by-8-hours-and-every-test-stayed-green-22f2</link>
      <guid>https://dev.to/libersand/a-postgres-driver-silently-shifted-my-timestamps-by-8-hours-and-every-test-stayed-green-22f2</guid>
      <description>&lt;p&gt;A chunk of the timestamp columns in my database were quietly wrong.&lt;/p&gt;

&lt;p&gt;Not corrupted. Not null. Just — eight hours off. The values looked perfectly reasonable in &lt;code&gt;psql&lt;/code&gt;. They looked reasonable in the app. They &lt;em&gt;were&lt;/em&gt; reasonable, for a timezone I wasn't in.&lt;/p&gt;

&lt;p&gt;Every test passed. CI was green. It had been green the whole time.&lt;/p&gt;

&lt;p&gt;Here's what was actually happening, and why it's invisible to most of the people who could have warned me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Nothing exotic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/porsager/postgres" rel="noopener noreferrer"&gt;postgres.js&lt;/a&gt; as the driver&lt;/li&gt;
&lt;li&gt;Drizzle ORM on top&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The schema looked like this — and if you use Drizzle, yours probably does too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;timestamp('created_at')&lt;/code&gt; is the important part. In Drizzle, &lt;code&gt;timestamp()&lt;/code&gt; with no options generates a Postgres &lt;code&gt;timestamp without time zone&lt;/code&gt; column — not &lt;code&gt;timestamptz&lt;/code&gt;. The default is the unsafe one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that are individually fine
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One: &lt;code&gt;timestamp without time zone&lt;/code&gt; stores no offset.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That isn't a bug, it's the definition of the type. Postgres stores the wall-clock digits you gave it — &lt;code&gt;2026-08-17 10:00:00&lt;/code&gt; — and nothing else. No zone, no offset. It has no idea whether that's 10am in Shanghai or 10am in Chicago, and it doesn't care.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: the driver has to guess on the way back.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you hand postgres.js a JavaScript &lt;code&gt;Date&lt;/code&gt;, it serializes it and sends it over. Postgres receives a value for a &lt;code&gt;timestamp without time zone&lt;/code&gt; column, discards the offset, and stores the literal wall clock.&lt;/p&gt;

&lt;p&gt;Then you read it back. Postgres hands the driver a bare string with no zone marker. The driver has to turn that into a JS &lt;code&gt;Date&lt;/code&gt;, and it has no offset to work with — so it resolves the string against the &lt;em&gt;runtime's local timezone&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Which is not necessarily the timezone the value was written in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The round trip
&lt;/h2&gt;

&lt;p&gt;My laptop is UTC+8. Trace one value through:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;new Date()&lt;/code&gt; in my app&lt;/td&gt;
&lt;td&gt;18:00 Beijing = &lt;strong&gt;10:00 UTC&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver serializes, Postgres strips the offset&lt;/td&gt;
&lt;td&gt;stored as &lt;code&gt;10:00&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read back: bare string, no zone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08-17 10:00:00&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver resolves against local time (UTC+8)&lt;/td&gt;
&lt;td&gt;10:00 Beijing = &lt;strong&gt;02:00 UTC&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wrote 10:00 UTC. Read 02:00 UTC.&lt;/p&gt;

&lt;p&gt;Eight hours. And it compounds — write that value back and you lose another eight.&lt;/p&gt;

&lt;p&gt;The number isn't special. It's just my UTC offset. In New York it would be five hours, in the other direction. In London during summer, one.&lt;/p&gt;

&lt;p&gt;And in UTC it is &lt;strong&gt;zero&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which is why every test passed
&lt;/h2&gt;

&lt;p&gt;Read that last line again, because it's the whole story.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub Actions runners: UTC&lt;/li&gt;
&lt;li&gt;Your Docker container, unless you went out of your way: UTC&lt;/li&gt;
&lt;li&gt;Your production server: almost certainly UTC&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the local offset is zero, the write-side and read-side interpretations agree exactly. The bug doesn't produce a small error under UTC. It produces &lt;em&gt;no error at all&lt;/em&gt;. This isn't a flaky test or a rare edge case — in a UTC environment the behavior is genuinely, completely correct.&lt;/p&gt;

&lt;p&gt;So my test suite wasn't failing to catch a bug. It was running in the one environment where the bug does not exist.&lt;/p&gt;

&lt;p&gt;There is exactly one way to trigger it: run code that reads and writes timestamps &lt;strong&gt;from a machine whose clock isn't UTC&lt;/strong&gt;. A developer laptop. Which is precisely what I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The expensive version
&lt;/h2&gt;

&lt;p&gt;I needed to backfill a column — copy timestamps from an old table into a new one. Straightforward script: read rows, transform, write rows.&lt;/p&gt;

&lt;p&gt;I ran it from my laptop.&lt;/p&gt;

&lt;p&gt;Every row it touched moved eight hours. The script was correct. The logic was correct. It would have been fine on the server. It just happened to make a round trip through a JavaScript runtime sitting in UTC+8, and paid the toll on the way through.&lt;/p&gt;

&lt;p&gt;The rule I now follow without exception:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A backfill must never round-trip a timestamp through the application layer.&lt;/strong&gt; Copy it inside the database.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;new_table&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;old_table&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres moving a value from one column to another cannot get the timezone wrong, because no timezone is ever inferred. There is no JS runtime in the path to guess with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it going forward
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;timestamptz&lt;/code&gt;.&lt;/strong&gt; In Drizzle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;timestamptz&lt;/code&gt; doesn't actually store a timezone either — that's a common misconception; Postgres normalizes to UTC internally. But the wire format carries an offset in both directions, so the driver never has to guess. The round trip becomes lossless no matter what timezone anything is running in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit what you already have.&lt;/strong&gt; My schema turned out to be a mix: a handful of newer tables had &lt;code&gt;withTimezone: true&lt;/code&gt; and everything older didn't. Nothing in the application code distinguishes them. This finds them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'timestamp%'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;TZ=UTC&lt;/code&gt; for your local runtime.&lt;/strong&gt; This is a band-aid, not a fix — it makes your laptop behave like CI, which hides the bug rather than removing it. But it stops you from making things worse while you migrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually took away
&lt;/h2&gt;

&lt;p&gt;I had assumed a green test suite meant the code was correct. What it actually meant was: &lt;em&gt;the code is correct in the environment the tests run in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Timezone is an ambient property of the machine, not an input to my tests. So is locale. So is filesystem case sensitivity. So is CPU architecture. My CI wasn't wrong — it just isn't a laptop in UTC+8, and it never will be.&lt;/p&gt;

&lt;p&gt;If your infrastructure is UTC top to bottom and your team isn't, that gap is where this class of bug lives. You don't close it by writing better tests. You close it by not letting the ambient environment participate in the answer — which, for timestamps, means &lt;code&gt;timestamptz&lt;/code&gt; and doing data migrations in SQL.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm an indie developer running several small SaaS products solo. This one cost me a day and a data migration, so I figured I'd write it down.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>node</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
