File format

Inside the .procreate file format

Procreate has never published its file format. This is what a .procreate file holds, worked out from two public sample files and from the open-source code that reads them: how the archive is laid out, how the pixels are stored and compressed, what the numbers in the layer settings mean, and which parts are still unknown.

Last updated

Every byte shown here comes from two files you can download and check yourself: mask-test.procreate (232 KB, one layer with a layer mask, saved in February 2026) and blend-modes.procreate (11.4 MB, one group per blend mode, saved in September 2022). Both are demo files from Silicate, an open-source Procreate viewer by An Tran, published under the MIT licence. Silicate’s parser is also the base of the one this site uses, so “Silicate does this” and “toPSD does this” are often the same code.

The container is a ZIP

$ unzip -l mask-test.procreate
  Length      Date    Time    Name
---------  ---------- -----   ----
     1596  02-26-2026 09:21   5B28C772-E880-4A8A-A4FB-E9D5B46C7126/0~6.lz4
     3118  02-26-2026 09:21   A925F0EE-1C42-48B2-8424-F7E1526D27D6/5~1.lz4
     1608  02-26-2026 09:21   5B28C772-E880-4A8A-A4FB-E9D5B46C7126/4~4.lz4
     1608  02-26-2026 09:19   1BDCB715-D4E4-4324-A581-2BF9F76D7C38/4~2.lz4
      ...
    61253  02-26-2026 09:21   video/segments/segment-1.mp4
      ...
    51702  02-26-2026 09:21   QuickLook/Thumbnail.png
      ...
     5655  02-26-2026 09:21   Document.archive
      ...
---------                     -------
   396546                     101 files

A .procreate file is an ordinary ZIP archive. It starts with the local file header signature 50 4b 03 04, and every entry in both samples is compressed with Deflate (method 8). The entries are:

  • Document.archive: the document itself, as a binary property list. 5,655 bytes in mask-test, 34,877 in blend-modes.
  • QuickLook/Thumbnail.png: an RGBA preview, already turned the right way up. It is 1024 × 1024 in mask-test and 1024 × 888 in blend-modes. Some online “converters” return only this image.
  • <UUID>/<column>~<row>.lz4 or .chunk: one folder per layer and one file per 256-pixel tile.
  • video/segments/segment-<n>.mp4: the time-lapse recording, in pieces.
  • Signature/Signature.png: only in blend-modes. Probably the artist signature from Procreate’s settings; it is not a layer.

Entry order means nothing. Both samples start with a tile, and Document.archive is entry 92 of 101 in mask-test and entry 119 of 350 in blend-modes. Always look entries up by name.

The tiles are compressed once by Procreate and then again by the ZIP. The second pass still helps on flat areas: a plain white tile in mask-test is 262,144 bytes of pixels, 1,596 bytes of LZ4 and 65 bytes after Deflate. Across the 98 tiles in that file, Deflate takes 277,936 bytes down to 116,956.

toPSD also accepts a copy that was unzipped and zipped again by a Mac or a cloud drive, which moves everything one folder down (Name.procreate/Document.archive), as long as exactly one folder holds a Document.archive. A ZIP with no Document.archive but a Tracks/ folder or a .dreams entry is taken to be a Procreate Dreams project, which is a different format.

Document.archive is an NSKeyedArchiver plist

$ unzip -p mask-test.procreate Document.archive | xxd -l 32
00000000: 6270 6c69 7374 3030 d400 0100 0200 0300  bplist00........
00000010: 0400 0500 0600 0700 0a58 2476 6572 7369  .........X$versi

bplist00 is Apple’s binary property list. Inside it is an NSKeyedArchiver archive, the format Cocoa uses to save a graph of objects. The top level has four keys:

$archiver  "NSKeyedArchiver"
$version   100000
$top       {root: UID(1)}
$objects   ["$null", {SilicaDocument}, "Mask Test File", "{2048, 2048}", ...]

All 55 objects in mask-test (392 in blend-modes) sit in the flat $objects array. A value that refers to another object is a UID, an index into that array, and UID(0) points at the string "$null", which means nil. Every object has a $class UID that leads to a {$classname, $classes} dictionary. Arrays are dictionaries with an NS.objects list of UIDs. The classes in both samples are SilicaDocument, SilicaLayer, SilicaGroup, ValkyrieColorProfile, ValkyrieDocumentAnimation, VideoSegmentInfo, NSArray, NSMutableArray and NSDictionary.

Python’s standard library reads it as it is:

import plistlib, zipfile

raw = zipfile.ZipFile("mask-test.procreate").read("Document.archive")
archive = plistlib.loads(raw)
objects = archive["$objects"]

def get(value):
    return objects[value.data] if isinstance(value, plistlib.UID) else value

doc = get(archive["$top"]["root"])
print(get(doc["$class"])["$classname"], get(doc["size"]), doc["tileSize"], doc["orientation"])
# SilicaDocument {2048, 2048} 256 3

The root SilicaDocument has about 45 keys. These are the ones a reader needs:

Key In mask-test Meaning
size "{2048, 2048}" Canvas width and height, as a string, before rotation
tileSize 256 Tile edge in pixels
orientation 3 Canvas rotation, 1 to 4 (see below)
flippedHorizontally, flippedVertically false Canvas flips
backgroundColor 16 bytes RGBA as four little-endian float32
backgroundHidden false Background colour switched off
colorProfile ValkyrieColorProfile ICC profile name and bytes
SilicaDocumentArchiveDPIKey 300.0 DPI
unwrappedLayers NSArray The layer tree, top first
layers NSMutableArray Every pixel layer in one flat list, no groups
composite SilicaLayer, type 1 Procreate’s own flattened image
mask SilicaLayer, type 2 A document-level mask, probably the selection
SilicaDocumentVideoSegmentInfoKey VideoSegmentInfo Time-lapse settings

The background colour in mask-test is 00 00 80 3f dd fb 7f 3f 00 00 80 3f 00 00 80 3f: 1.0, 0.99994, 1.0, 1.0, which is white with a rounding error in green. In blend-modes it is a teal (0, 0.671, 0.771) with backgroundHidden set, so that canvas is transparent. toPSD puts a visible background colour into the PSD as a bottom layer named “Background” and leaves it out when it is hidden.

To read the archive, toPSD uses silica, the parser crate from Silicate, copied in at commit 0e12deb with three changes: blend values it doesn’t know become Normal instead of failing the whole file (the raw number is kept), optional keys fall back to defaults, and group opacity is read.

How Document.archive points at the tile foldersIn mask-test.procreate, each SilicaLayer in Document.archive carries a UUID string. Layer 1, its mask and the composite each own a ZIP folder of that name holding their tiles. The thumbnail and the time-lapse sit beside them. Document.archive binary plist, NSKeyedArchiver Other ZIP entries a folder per UUID, a file per tile root SilicaDocument · size "{2048, 2048}" QuickLook/Thumbnail.png 1024 × 1024 RGBA PNG root.unwrappedLayers[0] SilicaLayer “Layer 1”, type 0 · UUID 1BDCB715… 1BDCB715-…/ 20 tiles, RGBA, .lz4 root.unwrappedLayers[0].mask SilicaLayer “Layer Mask”, type 3 · UUID A925F0EE… A925F0EE-…/ 14 tiles, 1 channel, .lz4 root.composite SilicaLayer, type 1 · UUID 5B28C772… 5B28C772-…/ 64 tiles, RGBA, .lz4 root.SilicaDocumentVideoSegmentInfoKey VideoSegmentInfo · {1080, 1080}, 30 fps video/segments/segment-1.mp4 H.264, 52 frames
Each layer in Document.archive carries a UUID string, and the ZIP has a folder of that name holding the layer's tiles. Layers with no pixels have no folder. Groups have no UUID at all.

Layers, groups and their order

A SilicaLayer has about 30 keys. The useful ones:

Key Meaning
UUID Name of the folder that holds the tiles
name Layer name, or nil
type 0 pixel layer, 1 composite, 2 document mask, 3 layer mask
opacity, hidden Float 0 to 1, and a bool
blend, extendedBlend Blend mode as an integer, stored twice (below)
clipped Clipping mask onto the layer below
mask Another SilicaLayer of type 3, or nil
preserve, locked Alpha Lock and layer lock
contentsRect The painted area as four float64

A SilicaGroup has name, children, isHidden, opacity, isClipped, isCollapsed, isLocked and isPrivate. It has no UUID, no blend key and no pixels of its own.

Index 0 is the top of the stack, in unwrappedLayers and inside every children array. In blend-modes, unwrappedLayers holds 27 items: a layer named “Layer 79”, then 26 groups named after blend modes, from “Divide” at the top down to “Normal”. Each group holds two layers, one of them set to that group’s mode. The flat layers list has all 53 pixel layers and no groups. Neither list contains the composite or the masks.

A PSD stores layer records bottom first, and a group is a run of records: a closing record named </Layer group> (section divider type 3), the children, then the folder record that carries the name and visibility (type 1, with the blend key pass, pass through, in its lsct block). toPSD walks Procreate’s tree from the end of each array to produce that order.

Tiles

Each layer is a grid of tileSize squares, with columns = ceil(width / 256) and rows = ceil(height / 256) taken from the stored size. A tile is named <column>~<row>, and row 0 is the bottom of the canvas. The rows of pixels inside each tile run bottom-up too, the way OpenGL and Core Graphics count.

Tiles on the right and top edges are stored at their real size, not padded to 256. blend-modes is 1836 × 2118, so its grid is 8 × 9, and tile 7~2.chunk of “Layer 79” decodes to 45,056 bytes, which is 44 × 256 × 4.

A tile with nothing painted on it has no file. “Layer 79” stores 45 of its 72 tiles; the single layer in mask-test stores 20 of 64.

The tile grid of one layerLayer 79 of blend-modes.procreate on its 8 by 9 grid of 256 pixel tiles. 45 tiles are stored and 27 are missing because they are empty. Column 7 is 44 pixels wide and row 8, at the top, is 70 pixels high. 1~0 4~3 6~7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 8 column (first number) row (second number), 0 at the bottom Column 7 is 1836 − 7 × 256 = 44 px wide. Edge tiles are stored at their real size. Row 8 is 2118 − 8 × 256 = 70 px high. It is at the top: rows count up from the bottom. a stored tile, e.g. 4~3.chunk (45 of them) no file: fully transparent (27 of 72)
"Layer 79" in blend-modes.procreate, drawn to scale. Filled squares are the tiles that exist in the ZIP.

toPSD flips each tile’s rows as it decodes it and places tile (column, row) at y = height − row × 256 − tile height, so everything after that step works top-down.

Two compressors, chosen per tile

The extension says how a tile is compressed: .chunk is LZO1X and .lz4 is LZ4 in Apple’s block framing. blend-modes, saved in 2022, has 325 tiles and all of them are LZO. mask-test, saved in 2026, has 98 and all of them are LZ4. Silicate’s demo folder also has two later saves of the blend-modes document: the 2023 one wrote only the composite as LZ4 and kept the other layers as LZO, and the 2026 one mixes both codecs, even inside one layer. Procreate seems to rewrite only the layers that changed, so a reader has to pick the decoder tile by tile.

.chunk: raw LZO1X

There is no header. The stream starts with the first LZO instruction and ends with the end-of-stream marker 11 00 00. Tile 7~0.chunk of one of the “Vivid Light” layers in blend-modes is 257 bytes and decodes to 45,056 (an edge tile, 44 px wide). Its first 99 bytes, and the last three:

02 00 00 00 00 00          literal run of 2 + 3 = 5 bytes, all zero
20 00 00 ... 00 96         match: length 31 + 2, plus 255 for each of the
                           89 zero bytes, plus 0x96 (150) = 22,878 bytes
01 00                      distance 1: repeat the previous byte
...
11 00 00                   end of stream

A match at distance 1 repeats the last byte, which is how empty areas compress. The first 22,883 decoded bytes are all zero: the bottom 130 rows of the tile, which are transparent, and three bytes of the next row. They take 99 bytes.

toPSD decodes LZO with the lzokay crate (MIT, pure Rust, decompression only). The licence matters here: the reference LZO library is GPL, as is the python-lzo package that two of the open-source Python readers depend on, and Silicate used a GPL binding until March 2026. The Linux kernel’s description of the LZO stream is enough to write a decoder from scratch. The one written to check this page is about 70 lines of Python, and it decodes all 325 LZO tiles in blend-modes to exactly the expected sizes.

.lz4: Apple’s LZ4 framing

Apple’s Compression framework wraps raw LZ4 blocks in small headers. This is the start of the white tile 0~6.lz4 from mask-test’s composite:

62 76 34 31    "bv41"     a compressed block follows
00 00 01 00    65,536     decoded size, u32 little-endian
82 01 00 00    386        encoded size
1f ff 01 00    ...        LZ4 data: one literal byte ff, then a long copy
                          from distance 1
...                       three more "bv41" blocks of 386 bytes
62 76 34 24    "bv4$"     end of stream

That is 4 × (12 + 386) + 4 = 1,596 bytes for 262,144 bytes of ff. In mask-test every colour tile is four blocks of 64 KiB, and every mask tile is one.

The blocks are chained: a copy can reach back into the output of earlier blocks, so each block has to be decoded with everything before it as the dictionary. 84 of the 98 tiles in mask-test do this. toPSD’s framing loop is ported from Silicate and calls lz4_flex::block::decompress_into_with_dict with all of the tile decoded so far. Apple also defines bv4- for a stored, uncompressed block, but its two documentation pages disagree on the header (one size field or two), and no sample contains one, so that layout is still a guess.

Pixels are premultiplied RGBA

Colour tiles hold 8 bits per channel in R, G, B, A order, with premultiplied alpha. A semi-transparent pixel from the Screen layer in blend-modes (tile 0~3.chunk, byte offset 548) is stored as 7b 5a 2d b3: red 123, green 90, blue 45, alpha 179. Divided by alpha, that is the colour (175, 128, 64). In all 321 colour tiles of blend-modes, no colour byte is ever larger than its alpha, which is what premultiplied data looks like.

PSD stores straight alpha, so toPSD divides every pixel as it decodes it: (c × 255 + a / 2) / a, capped at 255. It does this on plain byte arrays in WebAssembly. An HTML canvas would store the pixels premultiplied, losing precision, and could convert Display P3 colours to sRGB, so the pixels never go through one.

Layer masks are one byte per pixel, 255 meaning visible. Mask tile 3~1.lz4 in mask-test is a single bv41 block of 538 bytes that decodes to 65,536 bytes: 65,269 of them are 255, 142 are 128 and the other 125 lie in between. Part of this mask was painted at half strength.

Orientation, and a worked example

orientation is 1 for no rotation, 2 for 180°, 3 for 270° clockwise and 4 for 90° clockwise. To get the picture the artist saw, decode the tiles bottom-up, flip the rows, then rotate. Width and height swap for 3 and 4: blend-modes is stored as 1836 × 2118 with orientation 4 and comes out as 2118 × 1836. toPSD applies the two flip flags before the rotation, as Silicate does. No sample has a flip set, so that order hasn’t been checked against Procreate.

The layer in mask-test makes a good test, because its contentsRect says exactly where the paint is:

00 00 00 00 00 90 83 40    x = 626
00 00 00 00 00 50 77 40    y = 373   (from the bottom)
00 00 00 00 00 18 8a 40    w = 835
00 00 00 00 00 30 8f 40    h = 998

Counted from the top, the box starts at y = 2048 − 373 − 998 = 677. Orientation 3 sends a point (x, y) to (y, 2047 − x), so the box lands at x = 677, y = 2047 − (626 + 835 − 1) = 587, now 998 × 835. The layer in the PSD that toPSD writes for this file has exactly those bounds.

From stored tiles to the picture the artist sawThree squares show the 2048 pixel canvas of mask-test.procreate. Left: rows copied in stored order give an upside-down image. Middle: flipped, the canvas as stored, with the layer at x 626, y 677. Right: rotated 270 degrees clockwise for orientation 3, with the layer at x 677, y 587. Rows in stored order Tiles and the rows inside them run bottom-up, so copying them top-down turns the image over. Flipped: the canvas as stored Layer box: x 626, y 677, 835 × 998 px (contentsRect y is 373, from the bottom). Rotated for orientation 3 270° clockwise: what the artist saw. Layer box: x 677, y 587, 998 × 835 px, as in the PSD.
The 2048 × 2048 canvas of mask-test.procreate. The filled box is the layer's real painted area; the F stands in for the drawing so the turns are visible.

toPSD doesn’t read contentsRect. There is a contentsRectValid flag beside it, and in these samples it is false on both composites and on mask-test’s layer mask, so toPSD takes the bounds of the stored tiles and then trims fully transparent rows and columns. For mask-test both ways give the same box. Cropping keeps each layer in the PSD, and in memory, in proportion to its painted area.

Blend modes are integers, stored twice

Each layer has blend and extendedBlend. Modes that Procreate added later store 0 in blend and their real value only in extendedBlend. In blend-modes, the layers set to Hard Mix through Divide (20 to 26) all have blend = 0, and the Darken layer has 19 in both. Silicate and toPSD read extendedBlend and fall back to blend. Files saved in 2026 add a third key, extendedBlend2, which equals extendedBlend in every sample; nothing reads it yet.

Value Procreate PSD key toPSD writes
0 Normal norm
1 Multiply mul
2 Screen scrn
3 Add lddg (Linear Dodge)
4 Lighten lite
5 Exclusion smud
6 Difference diff
7 Subtract fsub
8 Linear Burn lbrn
9 Color Dodge div
10 Color Burn idiv
11 Overlay over
12 Hard Light hLit
13 Color colr
14 Luminosity lum
15 Hue hue
16 Saturation sat
17 Soft Light sLit
18 not used in any file or reader seen
19 Darken; shown as Shade since Procreate 5.4 dark
20 Hard Mix hMix
21 Vivid Light vLit
22 Linear Light lLit
23 Pin Light pLit
24 Lighter Color lgCl
25 Darker Color dkCl
26 Divide fdiv

The four-character keys come from Adobe’s Photoshop file format specification; five of them end in a space. Procreate 5.4 renamed the old Darken to Shade and added a new Darken that matches other apps. In the 2026 save of this document, the old “Darken” group and a new “Shade” group both hold layers set to 19, and nobody has published the number of the new Darken yet. toPSD writes 19 as Photoshop’s Darken, the closest mode it has. A number it doesn’t know is read as Normal.

Hue and Saturation don’t come out quite the same in Photoshop, because Procreate’s maths for them differs; toPSD says so in the export report when a file uses them. For its own flattened image, toPSD blends with the formulas from Adobe’s PDF blend mode addendum, ported from Silicate’s shader.

Masks and clipping

A layer mask is a separate SilicaLayer of type 3, hanging off the layer’s mask key, with its own UUID folder of one-channel tiles. mask-test’s mask stores 14 of its 64 tiles. Nobody knows yet whether Procreate treats a missing mask tile as hidden or shown. Silicate treats it as shown, and so does toPSD: it writes the mask cropped to the stored tiles, with a default colour of 255 so everything outside stays visible.

clipped clips a layer to the one below, and becomes the clipping flag on the PSD layer record. When several clipped layers sit in a row, toPSD clips each of them to the nearest unclipped layer below, the way Photoshop’s clipping groups work. Silicate clips each one to the layer directly below it. Neither sample on this site has a clipped layer.

Alpha Lock (preserve) and layer lock (locked) are in the file. toPSD doesn’t carry them into the PSD.

Colour

colorProfile is a ValkyrieColorProfile with two keys: SiColorProfileArchiveICCNameKey and SiColorProfileArchiveICCDataKey. Both samples embed the same 536-byte ICC profile, Apple’s Display P3. In mask-test the name key says “Display P3”; in blend-modes it says “ColorspaceDerived”, over the same bytes. So toPSD names the profile from the ICC bytes themselves, leaves the colour values in the file’s own colour space, and embeds the ICC bytes in the PSD as image resource 1039. A colour-managed editor then reads the pixels as Display P3, as Procreate does.

The composite and the thumbnail

composite is a type-1 layer holding Procreate’s own flattened render, with the background colour when it is visible, as ordinary tiles: all 64 in mask-test and 59 of 72 in blend-modes. It is not part of the layer tree. toPSD doesn’t use it for output. It flattens the layers itself, one 256-pixel tile at a time, and keeps Procreate’s composite as a reference for its tests. QuickLook/Thumbnail.png is 1024 px on the long side in both samples, which is why a converter that only unzips the thumbnail returns a small image.

The time-lapse

The recording is stored as video/segments/segment-1.mp4, segment-2.mp4 and so on. Sort them by number: the ZIP order is arbitrary, and as text segment-10 sorts before segment-2. blend-modes has 22 segments, all H.264 High profile, level 4.0, 1920 × 1080, tagged with Display P3 colour primaries. They run from a single frame (0.07 s) to 8.03 s. VideoSegmentInfo holds the settings: frameSize "{1920, 1080}", 30 fps, quality pro50.vqGood, resolution pro50.vr1080.

Each segment is a complete MP4, so joining them takes some care. Segments recorded by different app versions can carry different decoder configurations (avcC). The 2026 save of the blend-modes document has 67 segments with two configurations that differ only in max_num_ref_frames, 2 in the older segments and 4 in the newer ones. toPSD joins segments without re-encoding: it copies the coded frames as they are, rewrites the sample tables (stts, ctts, stss, stsc, stsz, stco) and the edit list, and keeps the configuration with the most reference frames. A segment whose configuration differs in anything other than the reference count or the level, or that doesn’t start on a keyframe, starts a new file, and the parts come out as a ZIP. For blend-modes, the result is one MP4 of 895 frames and 30.17 seconds: 7,744,827 bytes, made from 7,761,079 bytes of segments.

What is still unknown

  • The number of the Darken mode added in Procreate 5.4, and what extendedBlend2 is for.
  • Text layers: the text (a ValkyrieText object) and textPDF keys are nil in every sample.
  • Whether a missing mask tile means hidden or shown.
  • Whether flips apply before or after the rotation. No sample has a flip set, and no sample uses orientation 2.
  • sRGB canvases: the profile name, and whether some files store no profile at all.
  • The header of a stored bv4- LZ4 block in a real file.
  • Groups below 100% opacity and hidden groups. Every group in the samples is visible at 100%. toPSD applies group opacity when it flattens the image but doesn’t yet write it on the PSD folder.
  • What private layers are.

If you have a file that settles one of these, hello@topsd.app would like to hear about it, or open an issue on topsd/procreate-format, where these notes and the .brushset and .swatches formats are kept under CC BY 4.0.

Sources

  • Silicate by An Tran (MIT): its reader, its demo files (the two samples here) and its LZ4 framing and blend shader, which toPSD ports.
  • Apple’s documentation for the LZ4 format in the Compression framework.
  • The Linux kernel’s LZO stream format.
  • Adobe’s Photoshop File Format Specification, for the layer records, section dividers and blend keys.
  • The Procreate Handbook page on blend modes, for Shade.

toPSD, the converter on this site, does all of the above in the browser in Rust compiled to WebAssembly, and writes a layered PSD without uploading the file.