The Zouti Photos Template Language
A template is a small pattern that turns one photo’s metadata into a piece of text. It is
ordinary text with fields in braces mixed into it: everything outside the braces is copied
through exactly as typed, and each {…} is replaced with something from the photo.
{created.year} — {title,Untitled}
│ │ │
│ │ └─ used when the field has no value
│ └─ the photo's title
└─ the year the photo was taken
──▶ 2026 — Sunset over the bay
Zouti Photos implements the osxphotos Metadata Template Language. Templates written for one usually work in the other; the differences are listed at the end.
Every example on this page is real output. They were rendered by the template engine against the same example photo, described under The example photo.
Contents
- Where templates are used
- Quick start
- The parts of a field
- When a field has no value
- When a field has several values
- Field arguments
- Filters
- Find and replace
- Tests and yes/no text
- Combining two lists
- Variables
- Escaping
- Format codes
- The order things happen in
- Field reference — every field
- Filter reference — every filter
- Cookbook
- Errors
- Limits
- Differences from osxphotos
Where templates are used
| Where | What a template names |
|---|---|
| File ▸ Export Photos… | the destination folder, the filename, the edited/original suffixes, the report name, and the sidecar keyword template |
| Bulk Edit Metadata (⌥⌘B), Template mode | the title, caption, keywords, and albums written to each photo |
| Template Playground (⌥⌘Y) | nowhere — it renders against a real photo and writes nothing, so it is the place to work a template out before pasting it anywhere |
A saved export keeps its templates with it, so the Run Saved Export Shortcuts action and the AppleScript interface use whatever was saved.
The language is identical everywhere. Three things depend on where a template runs:
- Empty fields. In an export name an empty field renders
_, so you never get a silently blank filename. In Bulk Edit — where an empty result means “leave this photo alone” — it renders as nothing. See When a field has no value. /. A slash makes a folder level only in an export folder template. Everywhere else it stays part of the text.{counter}and{id}count position within the current run — the export, or the rows of the bulk-edit sheet.
Quick start
Six templates that cover most of what people want:
| Template | Renders |
|---|---|
{created.year}/{created.mm} |
2026/04 |
{created.date}-{original_name} |
2026-04-20-IMG_1234 |
{created.year}/{folder_album,No Album} |
2026/Best of 2026 and 2026/Trips/2026/Iceland |
{favorite?Favorites,All Photos}/{created.year} |
Favorites/2026 |
{place.country,Unknown Country}/{place.address.city,Unknown City} |
United States/San Francisco |
{original_name}-{counter:03d} |
IMG_1234-001 |
On a photo with no album, no place and no favorite flag, the same templates render
2026/No Album, All Photos/2026 and Unknown Country/Unknown City — that is what the comma
slot is for.
The parts of a field
Everything so far has used the two most common parts of a field: its name and its default. A field can carry more, always written in this order:
{ delim+ field (arg) |filter [find,replace] test &more ?yes ,no }
delim+ join this field's values into one value
field what to insert — the only required part
(arg) an argument, for the fields that take one
|filter change the value; chain as many as you like
[find,replace] replace text inside the value
test a comparison — the space before it matters
&more add another template's values to the list
?yes text to use when the result is true
,no text to use when it is false or empty
A colon after the field name carries a format code ({counter:05d}), except on
the handful of fields that give the colon slot another meaning ({format:int:03d,…},
{created.strftime,…}).
Every part except the field name is optional, and most templates use none of them.
When a field has no value
Not every photo has a title, a place, or an album. Add a comma inside the braces to choose what to write instead — and note the replacement can be another template rather than plain text.
{title,{original_name}}
│ │
│ └─ a default can itself be a template
└─ use the photo's title
On a photo with no title:
| Template | In an export name | In Bulk Edit |
|---|---|---|
{title} |
_ |
(nothing) |
{title,} |
(nothing) | (nothing) |
{title,{original_name}} |
IMG_9876 |
IMG_9876 |
{album,No Album} |
No Album |
No Album |
An explicitly empty default renders nothing at all, in either context: {title,} leaves the
spot blank rather than writing _.
A false yes/no field counts as “no value.” {hdr} on a non-HDR photo renders _, not False
— so {hdr,SDR} is how you label the other case. A true one renders the literal True.
When a field has several values
A photo can be in several albums and carry several keywords and people. A field like that produces a list, and what a list does depends on where the template is used:
| Context | A three-value list means |
|---|---|
| Export folder / filename | the photo is exported three times, once under each name |
| Bulk Edit ▸ Keywords | three keywords |
| Bulk Edit ▸ Albums | filed into three albums |
| Bulk Edit ▸ Title / Caption | the first value |
| Sidecar keyword template | three keywords |
Lists multiply when two of them appear in one template:
| Template | Renders |
|---|---|
{keyword} |
Travel + Vacation + Beach |
{keyword}-{person} |
Travel-Katie + Travel-Suzy + Vacation-Katie + Vacation-Suzy + Beach-Katie + Beach-Suzy |
{created.year}/{album}/{original_name} |
2026/Iceland/IMG_1234 + 2026/Best of 2026/IMG_1234 |
Six copies of a photo is rarely what anyone wants, so collapse a list to one value before it multiplies. There are two ways:
delim+ joins in place, before the filters run:
| Template | Renders |
|---|---|
{,+keyword} |
Travel,Vacation,Beach |
{+keyword} |
TravelVacationBeach |
{; +person} |
Katie; Suzy |
|join(x) joins later, after the other filters:
| Template | Renders |
|---|---|
{keyword|join(-)} |
Travel-Vacation-Beach |
{keyword|join(, )} |
Travel, Vacation, Beach |
{keyword|sort|join(_)} |
Beach_Travel_Vacation |
The difference is when: {,+keyword|upper} joins first and then upper-cases the whole string,
while {keyword|upper|join(,)} upper-cases each keyword and joins afterwards. They agree here, and
stop agreeing as soon as a filter treats one long string differently from several short ones.
Multi-value fields: {keyword}, {album}, {folder_album}, {person}, {label},
{label_normalized}, {detected_text}. Everything else renders one value or none.
Field arguments
A few fields accept an argument in parentheses.
{folder_album} renders an album’s full path in the Photos sidebar, joined with / so an
export folder template recreates your library’s folder tree. Pass a different separator to use it
in a filename instead:
| Template | Renders |
|---|---|
{folder_album} |
Best of 2026 + Trips/2026/Iceland |
{folder_album(>)} |
Best of 2026 + Trips>2026>Iceland |
{folder_album( › )} |
Best of 2026 + Trips › 2026 › Iceland |
A / inside a folder or album title is part of the name, not a level of nesting, and is
rewritten to - so it cannot split the path.
{counter(start,stop,step)} configures a counter — see Counters.
Filters
A filter changes a value after the field produces it. Append it with |, and chain as many as you
like — they run left to right, each working on what the last handed it.
{person|sort|uniq|join(, )}
│ │ └─ …joined with a comma and a space
│ └─ …duplicates dropped…
└─ people sorted…
Filters come in two kinds, and the distinction matters:
- Text filters work on each value —
{keyword|upper}on three keywords gives three upper-cased keywords. - List filters work on the list as a whole —
{keyword|join(-)}gives one value.
Two pairs are easy to confuse:
| Acts on each value | Acts on the list | |
|---|---|---|
| Slice | sslice — {title|sslice(0:6)} → Sunset |
slice — {keyword|slice(0:2)} → Travel + Vacation |
| Add | appends — {keyword|appends(!)} → Travel! + Vacation! + Beach! |
append — {keyword|append(Trip)} → Travel + Vacation + Beach + Trip |
The s in appends/prepends is for string.
A filter argument may itself be a template. {keyword|join({comma})} joins with a comma, and
{keyword|append({person})} adds the face names to the keywords. append, prepend and remove
use every value the argument renders — and none, if it renders nothing, so a photo with no
faces simply keeps its keywords. Every other filter needs its argument to come to exactly one
value. To put a literal brace in an argument, escape it: {title|appends(\{draft\})} →
Sunset over the bay{draft}.
The complete list is in the Filter reference.
Find and replace
Square brackets after the field replace text inside each value. Separate several pairs with |.
| Template | Renders |
|---|---|
{title[ ,_]} |
Sunset_over_the_bay |
{title[Sunset,Sunrise|bay,harbor]} |
Sunrise over the harbor |
{album[Best of ,]} |
Iceland + 2026 |
{album[/,-]} |
Iceland + Best of 2026 |
An empty replacement deletes the found text. Find and replace runs before the filters, so
{album[Beach,Shore]|lower} replaces first and lower-cases the result.
Tests and yes/no text
A field can be used as a question rather than as text. Follow it with ?, and the template writes
one thing when the answer is yes and another when it is no.
{favorite?Favorites,Everything Else}
│ │ │
│ │ └─ used when it is false
│ └─ used when the field is true
└─ a yes/no field: is the photo a favorite?
{hdr}, {favorite}, {hidden} and {edited} are true/false fields. Any other field counts as
true when the photo has a value for it, so {title?Titled,Untitled} sorts photos by whether they
have a title at all.
For anything finer, write a comparison between the field and a value. The space before the comparison word is required — it is what separates the test from the field name.
{keyword contains Beach?At the Beach,Elsewhere}
│ │ │ │ │
│ │ │ │ └─ used when it does not
│ │ │ └─ used when the test passes
│ │ └─ the value compared against
│ └─ the comparison (a space before it is required)
└─ the field being tested
| Operator | Meaning |
|---|---|
contains |
the value contains this text |
startswith / endswith |
the value begins / ends with this text |
matches |
the value matches this regular expression |
== / != |
exactly equal / not equal |
< <= > >= |
compared as numbers when both sides are numbers, otherwise as text |
Every operator can be reversed with not in front of it. On a multi-value field the test passes
when any value satisfies it.
| Template | Renders |
|---|---|
{title?Titled,Untitled} |
Titled |
{keyword contains Beach?At the Beach,Elsewhere} |
At the Beach |
{title startswith Sun?Yes,No} |
Yes |
{title matches ^Sun.*bay$?Yes,No} |
Yes |
{created.year >= 2026?Recent,Older} |
Recent |
{place.country == United States?Domestic,International} |
Domestic |
{title not contains Beach?No beach,Beach} |
No beach |
Several candidates can be tested at once by separating them with |:
{keyword contains Beach|Ocean?Coastal,Inland}.
Combining two lists
& appends a second template’s values onto the first field’s list. It is the way to build one list
out of two.
| Template | Renders |
|---|---|
{folder_album(>)&{person}} |
Best of 2026 + Trips>2026>Iceland + Katie + Suzy |
{keyword&{label}} |
Travel + Vacation + Beach + Beach + Sunset + Sky |
Either side may be empty: a photo in no albums contributes only the face names, and one with
neither falls back to the expression’s own default. Chain three lists by nesting:
{keyword&{folder_album(>)&{person}}}.
In a name template remember the result is a list, so the photo is exported once per value; in the sidecar keyword template and in Bulk Edit, several values is exactly the point.
& or |append(…)?
{field|append({other})} joins two lists too, and mostly they are interchangeable. One difference
decides it: filters run before &, so a filter written after a combine is not applied to the
combined list at all — it lands inside the operand as literal text.
| Template | Renders |
|---|---|
{keyword&{label}} |
Travel + Vacation + Beach + Beach + Sunset + Sky |
{keyword&{label}|uniq} |
Travel + Vacation + Beach + Beach|uniq + Sunset|uniq + Sky|uniq ✗ |
{keyword|append({label})|uniq} |
Travel + Vacation + Beach + Sunset + Sky ✓ |
So: reach for & when you just want the two lists side by side, and for |append(…) when anything
has to happen to the result afterwards. |append also chains, which is the tidiest way to merge
three lists:
{keyword|append({label})|append({person})|uniq}
──▶ Travel + Vacation + Beach + Sunset + Sky + Katie + Suzy
Variables
A template can name a value and reuse it later in the same template. {var:NAME,VALUE} defines
one and writes nothing itself; {%NAME} reads it back.
| Template | Renders |
|---|---|
{var:y,{created.year}}{original_name}-{%y} |
IMG_1234-2026 |
{var:tags,{keyword}}{%tags|sort|join(-)} |
Beach-Travel-Vacation |
{var:pipe,{pipe}}{keyword|join(%pipe)} |
Travel|Vacation|Beach |
- Names may contain letters, numbers and underscores, and must be defined before they are used. A variable lives for one render of one template.
- A variable keeps its list of values and can be filtered when read.
- A variable holding nothing contributes nothing.
%NAME— without braces — also works inside a join delimiter, a find/replace pair, and a filter argument. In the first two it must come to exactly one value;append,prependandremoveaccept a list. Write%%for a literal percent sign in those three places only — everywhere else%is already ordinary text.- Variables are deliberately absent from the graphical builder.
Since a filter argument can be a template, a variable is rarely needed just to reach a
second field: {%alb|append(%per)} and {folder_album(>)|append({person})} do the same thing.
Escaping
A backslash makes the next character ordinary text. This is a Zouti Photos extension — osxphotos has no escape character, so use the literal-character fields instead if the template has to work in both.
Escapable: { } \ , ? & | + ( ) [ ] and a space. A backslash before anything
else is just a backslash, so a Windows-style path in a template is left alone.
| Template | Renders (photo with no title) |
|---|---|
{title,Sun\, sand} |
Sun, sand |
{title,Sun{comma} sand} |
Sun, sand |
{title,a\{b\}c} |
a{b}c |
{title,50% off} |
50% off |
It works anywhere text is expected: defaults, filter arguments, find/replace pairs, field
arguments, and format codes. {keyword|join(\,)} joins with commas; {counter:\,d} writes 1,234
rather than 1234.
The graphical builder cannot draw escapes — a template containing one stays text-only.
Format codes
A Python format code after a colon pads or formats any field’s value. These follow Python’s format specification mini-language, which is the place to look for the full set of options:
[[fill]align][sign][#][0][width][grouping][.precision][type]
Alignments are < > ^ =; types are d b o x X f F e E g G % s. With no type, a value that
looks like an integer is formatted as one when the spec asks for numeric padding, and as a string
otherwise. Zouti Photos implements a practical subset — the pieces that make sense for the string
values a template field produces.
| Template | Renders |
|---|---|
{counter:05d} |
01234 (at position 1234) |
{photo.width:08d} |
00004032 |
{photo.width:x} |
fc0 |
{title:.10} |
Sunset ove |
{title:>25} |
Sunset over the bay |
{title:*^27} |
****Sunset over the bay**** |
{counter:\,d} |
1,234 |
The grouping option needs the escape — a bare comma would start the default slot first. Grouping applies to integers only.
To convert before formatting, use {format:TYPE:CODE,TEMPLATE}.
The order things happen in
When several parts appear on one field, this is the order they are applied — worth knowing when a template does not do what it looks like it should:
the field produces a list of values
↓
[find,replace] text replaced inside each value
↓
delim+ the list joined into a single value
↓
|filters left to right, each on the last result
↓
&more another template's values added
↓
test / ?yes / ,no what actually gets written
Field reference
Dates
Four date sources: created (when the photo was taken), added (when it was imported),
modified (when it was last changed), and today (the date the template runs — a property
of the export, not of the photo).
added and modified fall back to the capture date when the library recorded none.
Follow any source with a component. Bare {created} is the same as {created.date}.
| Component | Example photo | Notes |
|---|---|---|
.date |
2026-04-20 |
ISO order, always zero-padded |
.year |
2026 |
four digits |
.yy |
26 |
two digits |
.mm |
04 |
month number, zero-padded |
.month |
April |
full month name |
.mon |
Apr |
abbreviated month name |
.dd |
20 |
day of month, zero-padded |
.dow |
Monday |
day of week |
.doy |
110 |
day of year, three digits |
.hour |
18 |
24-hour, zero-padded |
.min |
32 |
|
.sec |
07 |
|
.week |
17 |
week of year |
{modified.date} → 2026-05-02. {today.date} → the day you run the export.
Custom date formats
{created.strftime,PATTERN} formats the date with a strftime pattern given in the comma slot.
This strftime reference is a handy place to look up
the codes and see them previewed.
| Template | Renders |
|---|---|
{created.strftime,%Y-%m-%d %H:%M:%S} |
2026-04-20 18:32:07 |
{created.strftime,%B %-d, %Y} |
April 20, 2026 |
{created.strftime,%Y/%m} |
2026/04 |
{created.strftime,%A %B %-d} |
Monday April 20 |
{created.strftime,%j} |
110 |
{created.strftime,%s} |
1776709927 (Unix time) |
Because the pattern occupies the comma slot, a .strftime field takes no default.
Dates render in the time zone the export is configured to use, so
{created.hour}answers the question you are actually asking rather than reporting the photo’s instant in your Mac’s zone.
Names and identity
| Field | Example photo | What it is |
|---|---|---|
{original_name} |
IMG_1234 |
the name the file had when taken or imported, without the extension — usually the one you want |
{original_filename} |
IMG_1234.HEIC |
the same name with its extension |
{name} |
5C6D1E2F-3A4B-4C5D-8E9F-0A1B2C3D4E5F |
the name of the file inside the Photos library, without the extension — usually a long identifier |
{filename} |
5C6D1E2F-…-0A1B2C3D4E5F.heic |
the in-library name with its extension |
{img_number} |
1234 |
the sequence number out of a camera-canonical original name |
{uuid} |
1A2B3C4D-5E6F-4A8B-9C0D-1E2F3A4B5C6D |
the photo’s identifier in the library |
{shortuuid} |
6fQMGY3ZjfN5ZcYjsqnjE3 |
a compact base-57 encoding of the same identifier |
{id} |
1 |
the photo’s position in the current run, starting at 1 |
{current_name} is another spelling of {name}.
Export appends the real extension itself, so the *_name forms are the right ones for a filename
template and the *_filename forms are for folder names and other text.
{img_number} is deliberately strict. The original name must be IMG_ followed by exactly
four digits, so IMG_E1234, IMG_12345, DSC_1234 and MyPhoto2025 all render nothing at all.
That is what makes it useful with a fallback: {img_number,{original_name}} numbers the photos
that came from a camera and keeps the original name for everything else.
Counters
{counter} is a sequential counter over the photos in the current run, starting at 1.
{counter.a}, {counter.b} and so on are independently configured counters.
{counter(start,stop,step)} configures it. stop is never reached — the counter resets to start
when it would pass it.
At position 7 of a run:
| Template | Renders |
|---|---|
{counter} |
7 |
{counter:03d} |
007 |
{counter(0)} |
6 (counts from 0) |
{counter(,,10)} |
61 (1, 11, 21, …) |
{counter(100,,5)} |
130 (100, 105, 110, …) |
{counter(1,4)} |
1 (1, 2, 3, then back to 1) |
A counter always derives from the photo’s position, so re-rendering a photo never changes its number — a preview shows the same value the export will write.
What you typed
| Field | Example photo | Notes |
|---|---|---|
{title} |
Sunset over the bay |
empty for most photos, so it usually wants a default |
{caption} |
Golden hour at the marina |
also spelled {descr} |
{keyword} |
Travel + Vacation + Beach |
multi-value; in library order |
{album} |
Iceland + Best of 2026 |
the album’s own name; multi-value, in library order |
{folder_album} |
Best of 2026 + Trips/2026/Iceland |
the album’s full sidebar path; multi-value, sorted |
{person} |
Katie + Suzy |
each person Photos has named in the photo; multi-value |
{keywords}, {albums} and {persons} are accepted as plural spellings of the same fields.
An album not inside a folder renders identically as {album} and {folder_album}.
What Photos recognized
These come from the Photos search index, so they are empty for photos Photos has not analyzed
yet. Machine-generated — unlike {keyword}, which is what you typed.
| Field | Example photo | Notes |
|---|---|---|
{label} |
Beach + Sunset + Sky |
what Photos recognized in the image; multi-value |
{label_normalized} |
beach + sunset + sky |
the same labels, lower-cased |
{detected_text} |
PIER 39 |
text Photos found inside the image; multi-value |
{labels} is accepted as a plural spelling of {label}.
Location
Two families, and they are not the same thing. {place.address.…} are the postal fields.
{place.name.…} are what the map service named that level of the hierarchy, which is not
always the postal spelling; each falls back to the matching postal field when Photos recorded no
name.
| Field | Example photo |
|---|---|
{place.name} |
Marina Green |
{place.address} |
3950 Scott St, San Francisco, CA 94123 |
{place.address.city} |
San Francisco |
{place.state} |
California |
{place.country} |
United States |
{place.country_code} |
US |
{place.address.postal_code} |
94123 |
{place.address.street} |
3950 Scott St |
{place.name.city} |
San Francisco |
{place.name.state_province} |
California |
{place.name.country} |
United States |
{place.name.area_of_interest} |
Marina Green |
Alternative spellings, all resolving to the field above: {place.city},
{place.address.state_province}, {place.state_province}, {place.address.country}.
Every one of these renders a single value. Photos can store several names at one level; the
first is taken, because a multi-value {place.name.city} would silently export one copy of the
photo per alternate spelling.
Camera
| Field | Example photo |
|---|---|
{exif.camera_make} |
Apple |
{exif.camera_model} |
iPhone 15 Pro |
{exif.lens_model} |
iPhone 15 Pro back triple camera 6.765mm f/1.78 |
Also spelled {camera_make}, {camera_model}, {lens_model}.
Where it came from
| Field | Example photo | Notes |
|---|---|---|
{moment} |
Marina Green |
the title Photos gives the moment this photo belongs to |
{imported_by} |
Photos |
the app that added the photo to the library |
{imported_by.id} |
com.apple.Photos |
that app’s bundle identifier |
{imported_by.name} is another spelling of {imported_by}.
Status
{hdr}, {favorite}, {hidden} and {edited} are true/false. A true one renders the literal
True; a false one counts as no value and takes the default — which is why these fields are
almost always written as a question.
| Template | Renders |
|---|---|
{favorite} |
True |
{hdr} |
_ (the photo is not HDR) |
{favorite?Favorites,Everything Else} |
Favorites |
{edited?Edited,Original} |
Edited |
{hdr?HDR,SDR} |
SDR |
{edited} means “an edited version of this photo exports”, which is slightly wider than “the user
edited it”: a capture-time slow-motion movie still exports a retimed rendition.
Media types
{media_type} reports the most specific type Photos recorded, testing in this order and taking
the first that fits. {photo_or_video} answers only the two-way question.
{media_type} |
{photo_or_video} |
Recognized by |
|---|---|---|
selfie |
photo |
taken with the front camera |
time_lapse |
video |
a time-lapse movie |
panorama |
photo |
a panoramic photo |
slow_mo |
video |
a slow-motion movie |
screenshot |
photo |
a screenshot |
screen_recording |
video |
a screen recording |
portrait |
photo |
a photo with a depth map (Portrait mode) |
live_photo |
photo |
a Live Photo |
burst |
photo |
one frame of a burst |
photo / video |
photo / video |
everything else |
Because these fields always have a value, their comma slot is put to a different use: it renames types rather than supplying a default.
{media_type,video=movie;slow_mo=slowmo}
writes movie and slowmo and leaves every other type alone. Only the names in the table may
appear on the left, and each needs a replacement — a comma slot that is not a list of type=name
pairs is an error, rather than a default that could never fire.
Photo properties
{photo.…} reaches the scalar properties of Zouti Photos’s own metadata record. Bare {photo} is
the UUID. Dates here are ISO 8601. A true/false property behaves like {favorite}: false counts as
no value, so it takes the default.
| Field | Example photo |
|---|---|
{photo.uuid} |
1A2B3C4D-5E6F-4A8B-9C0D-1E2F3A4B5C6D |
{photo.assetPK} |
1 |
{photo.filename} |
5C6D1E2F-…-0A1B2C3D4E5F.heic |
{photo.originalFilename} |
IMG_1234.HEIC |
{photo.title} |
Sunset over the bay |
{photo.caption} |
Golden hour at the marina |
{photo.accessibilityDescription} |
(none on this photo) |
{photo.importedBy} |
Photos |
{photo.creationDate} |
2026-04-20T18:32:07Z |
{photo.addedDate} |
2026-04-20T18:32:07Z |
{photo.modificationDate} |
2026-05-02T09:15:00Z |
{photo.viewedDate} |
(none on this photo) |
{photo.editedDate} |
(none on this photo) |
{photo.sharedDate} |
(none on this photo) |
{photo.favorite} |
True |
{photo.hidden} |
(false — takes the default) |
{photo.width} |
4032 |
{photo.height} |
3024 |
{photo.kind} |
0 (0 = image, 1 = video) |
{photo.kindSubtype} |
(none on this photo) |
{photo.hdrType} |
(none on this photo) |
{photo.isHDR} |
(none on this photo) |
{photo.uti} |
(none on this photo) |
{photo.orientation} |
(none on this photo) |
{photo.hasAdjustments} |
True |
{photo.hasSlowMotionRendition} |
(none on this photo) |
{photo.isCloudAsset} |
(none on this photo) |
{photo.inCloud} |
(none on this photo) |
{photo.isMissing} |
(none on this photo) |
{photo.aiCaption} |
A sunset over water with boats |
{photo.width}x{photo.height} → 4032x3024.
Records and lists are deliberately left out — use the album, keyword, person, location and camera fields for those. An unknown or non-scalar property name is an error.
Transforming another template
Three fields take a template in the comma slot and transform whatever it renders. Because that slot is the argument, these fields never take a default.
| Field | Example | Renders |
|---|---|---|
{strip,TEMPLATE} |
{strip, {title} } |
Sunset over the bay |
{shell_quote,TEMPLATE} |
{shell_quote,{title}} |
'Sunset over the bay' |
{format:TYPE:CODE,TEMPLATE} |
{format:int:03d,{counter}} |
001 |
TYPE is int, float or str; CODE is a format code. int goes through a
float first, so a value of 3.7 can be formatted as an integer instead of erroring.
The argument keeps its whole value list, so {shell_quote,{keyword}} quotes each keyword
rather than collapsing them: 'Travel' + 'Vacation' + 'Beach'.
More examples: {format:float:.2f,3.14159} → 3.14; {format:str:>20,{title}} →
Sunset over the bay.
Literal characters
A comma, brace or parenthesis would otherwise be read as part of the syntax, so each has a field of its own. A backslash escape reaches the same characters more briefly, but these spellings are what osxphotos templates use and are the portable choice.
| Field | Character | Field | Character | |
|---|---|---|---|---|
{comma} |
, |
{pipe} |
| |
|
{semicolon} |
; |
{percent} |
% |
|
{ampersand} |
& |
{question} |
? |
|
{openbrace} |
{ |
{space} |
(a space) | |
{closebrace} |
} |
{tab} |
(a tab) | |
{openparen} |
( |
{newline} |
(a newline) | |
{closeparen} |
) |
{cr} |
(carriage return) | |
{openbracket} |
[ |
{lf} |
(line feed) | |
{closebracket} |
] |
{crlf} |
(both) |
osxphotos spells three of them differently and both spellings work here: {questionmark},
{openparens}, {closeparens}.
Filter reference
Text filters
Applied to each value in the list.
| Filter | What it does | Example | Renders |
|---|---|---|---|
|lower |
lower-cases every value | {title|lower} |
sunset over the bay |
|upper |
upper-cases every value | {title|upper} |
SUNSET OVER THE BAY |
|strip |
removes leading and trailing whitespace | {title|strip} |
Sunset over the bay |
|capitalize |
upper-cases the first character, lower-cases the rest | {title|capitalize} |
Sunset over the bay |
|titlecase |
capitalizes the first letter of each word | {title|titlecase} |
Sunset Over The Bay |
|braces |
wraps each value in { } |
{title|braces} |
{Sunset over the bay} |
|parens |
wraps each value in ( ) |
{title|parens} |
(Sunset over the bay) |
|brackets |
wraps each value in [ ] |
{title|brackets} |
[Sunset over the bay] |
|shell_quote |
quotes each value for a shell | {title|shell_quote} |
'Sunset over the bay' |
|chop(n) |
removes n characters from the end | {original_name|chop(2)} |
IMG_12 |
|chomp(n) |
removes n characters from the front | {original_name|chomp(4)} |
1234 |
|sslice(start:stop:step) |
cuts inside each value, Python-style | {title|sslice(0:6)} |
Sunset |
|zfill(n) |
left-pads with zeros to n characters | {img_number|zfill(8)} |
00001234 |
|appends(x) |
adds text to the end of every value | {keyword|appends(!)} |
Travel! + Vacation! + Beach! |
|prepends(x) |
adds text to the front of every value | {keyword|prepends(#)} |
#Travel + #Vacation + #Beach |
|int |
converts a numeric value to a whole number | {photo.width|int} |
4032 |
|float |
converts a numeric value to a decimal number | {photo.width|float} |
4032.0 |
chop and chomp default to 1 character. zfill keeps a leading sign ahead of the padding, so
-1 becomes -001.
sslice takes Python’s start:stop:step, so bounds may be negative and a negative step counts
backwards:
| Template | Renders |
|---|---|
{title|sslice(0:6)} |
Sunset |
{title|sslice(-3:)} |
bay |
{title|sslice(::-1)} |
yab eht revo tesnuS |
List filters
Applied to the list as a whole.
| Filter | What it does | Example | Renders |
|---|---|---|---|
|sort |
sorts the values alphabetically | {keyword|sort} |
Beach + Travel + Vacation |
|rsort |
sorts them in reverse | {keyword|rsort} |
Vacation + Travel + Beach |
|reverse |
reverses the order without sorting | {keyword|reverse} |
Beach + Vacation + Travel |
|uniq |
drops duplicates, keeping the first of each | {keyword|sort|uniq|join(-)} |
Beach-Travel-Vacation |
|join(x) |
joins every value into one, separated by x | {keyword|join(-)} |
Travel-Vacation-Beach |
|split(x) |
splits each value on x, producing more values | {title|split( )} |
Sunset + over + the + bay |
|autosplit |
splits on commas, semicolons and newlines | {keyword|autosplit} |
Travel + Vacation + Beach |
|slice(start:stop:step) |
cuts the list, Python-style | {keyword|slice(0:2)} |
Travel + Vacation |
|append(x) |
adds x to the end of the list | {keyword|append(Trip)} |
Travel + Vacation + Beach + Trip |
|prepend(x) |
adds x to the start of the list | {keyword|prepend(Trip)} |
Trip + Travel + Vacation + Beach |
|remove(x) |
drops any value equal to x | {keyword|remove(Beach)} |
Travel + Vacation |
|split defaults to splitting on a space. |slice(::-1) reverses the list.
append, prepend and remove take a list. Their argument may be a template, and every value
it renders is used — or none, if it renders nothing:
| Template | Renders |
|---|---|
{keyword|append({person})} |
Travel + Vacation + Beach + Katie + Suzy |
{keyword|append({person})} (photo with no faces) |
Travel |
{keyword|remove({label})} |
the keywords that are not also labels |
Every other filter needs its argument to come to exactly one value.
Cookbook
Export folders
| Goal | Template |
|---|---|
| Year, then month number | {created.year}/{created.mm} |
| Year, then a readable month | {created.year}/{created.year}-{created.mm}-{created.month} |
| Recreate the Photos folder tree | {created.year}/{folder_album,No Album} |
| Split favorites out | {favorite?Favorites,All Photos}/{created.year} |
| By where it was taken | {place.country,Unknown Country}/{place.address.city,Unknown City} |
| By media type | {created.year}/{media_type} |
| One folder per person in the photo | {created.year}/{person,No People} |
The album and person templates above are multi-value: a photo in two albums is exported into both. That is usually the point of them.
Export filenames
| Goal | Template |
|---|---|
| Keep the camera’s name | {original_name} |
| Date-stamp it | {created.date}-{original_name} |
| Sortable to the second | {created.strftime,%Y-%m-%d_%H%M%S}-{original_name} |
| Time only, after the name | {original_name}_{created.strftime,%H%M%S} |
| Number them | {original_name}-{counter:03d} |
| Guaranteed unique | {created.year}/{created.mm}/{original_name}-{shortuuid} |
| Camera number, name for everything else | {img_number,{original_name}} |
| Title if there is one | {title,{original_name}} |
| Keywords in the name | {original_name}-{keyword|sort|join(_),} |
|join on a photo with no keywords produces one empty value, which in an export name becomes
_ — {original_name}-{keyword|sort|join(_)} renders IMG_9876-_. The trailing , in the recipe
above is an explicitly empty default, which renders
IMG_9876- instead.
Sidecar keywords
The keyword template turns every rendered value into one keyword, so multi-value is what you want here.
| Goal | Template |
|---|---|
| Face names as keywords | {person} |
| Album paths as keywords | {folder_album(>)} |
| Both at once | {folder_album(>)&{person}} |
| Your keywords plus Photos’ labels | {keyword&{label}} |
| Everything, deduplicated | {keyword|append({label})|append({person})|uniq} |
| Year as a keyword | {created.year} |
Bulk Edit
| Goal | Field | Template |
|---|---|---|
| Give untitled photos a title | Title | {title,{original_name} — {created.date}} |
| Stamp the place into the caption | Caption | {place.address.city,}{place.country?\, {place.country},} |
| Add the capture year as a keyword | Keywords | {created.year} |
| Copy detected labels into keywords | Keywords | {label} |
| File by year and month | Albums | {created.year}/{created.month} |
Remember that a blank rendered value means “leave this photo alone”, so {title,} is a safe no-op
rather than a mass clear.
Errors
An unknown field, filter, photo property or variable name is an error rather than an empty result, so a mistyped template never quietly writes a misleading name.
| Template | Error |
|---|---|
{bogus} |
unknown field |
{created.yearr} |
'yearr' is not a date component |
{title|nosuch} |
unknown filter |
{%undefined} |
variable 'undefined' is not defined |
{media_type,junk} |
'junk' is not 'TYPE=NAME' |
{keyword|slice(0:x)} |
slice bounds must be integers |
{exiftool:Make} |
exiftool is excluded |
{function:mymod.py::myfn} |
function: executes external code |
Errors that depend only on the template — a misspelled field, an unknown filter, a malformed
slice — are reported as you type. Errors that depend on a value — {format:int:03d,{title}} on a
photo whose title is not a number — are reported per photo when the template runs.
Whatever you are writing a template for, preview it before committing to it. The export window’s Preview plans every name without writing a file, and Bulk Edit’s table shows the exact value each photo would receive. Both are the quickest way to see multi-value expansion, defaults that fired, and results you did not intend.
Limits
Templates are user input, and a short one can ask for an unbounded amount of work. Three limits keep a mistake to an error message rather than a hung app:
| Limit | Value | What hits it |
|---|---|---|
| Nesting depth | 64 | {…} inside {…} inside {…} |
| Format width | 10,000 | {title:99999999}, |zfill(99999999) |
| Rendered values | 10,000 | multi-value fields multiplying — eight {keyword} references on a photo with twelve keywords is 12⁸ |
Differences from osxphotos
Zouti Photos implements the useful core of the osxphotos metadata template language, and templates written for one usually work in the other, but it is not a drop-in replacement.
Not implemented
{function:…},|function:…and{exiftool:…}do not exist here. A sandboxed app cannot run external code, and Zouti Photos has no dependency on exiftool.{detected_text}reads what Photos already detected rather than running text detection, so it takes no confidence threshold.
Different
{photo.…}follows Zouti Photos’s own metadata record, so its property names are not the osxphotosPhotoInfonames.{counter}starts at 1 rather than 0 — use{counter(0)}for the old behavior — and always derives from the photo’s position rather than from how many times it has been evaluated, so re-rendering never changes a number.{id}is that position rather than a database key.- An unknown field, filter, property or variable is an error rather than an empty result. The
same goes for a misspelled date component, a malformed slice, and a
{media_type}rename list that is not one. - A
+after the field name no longer starts a join. osxphotos scans the whole{…}for the last+, so{title,a+b}there means “join withtitle,a” — almost never what was meant. Here a field name followed by any of: ( | [ & ? ,or a space rules the join out. The cost is that a join delimiter containing one of those characters needs an escape:{a\ b+keyword}.
Additions
- Backslash escaping. osxphotos has no escape character at all; the
{comma}-style fields are the portable spelling. - A filter argument may be a template.
{keyword|append({person})}works here and is literal text there. append,prependandremovetake a list, not just a single value.- Format codes work on every field, not only the numeric ones.
zfilland{space}are additions, as are{semicolon},{ampersand},{openbracket}and{closebracket}.
The example photo
Every rendered value on this page comes from one photo:
| Original filename | IMG_1234.HEIC |
| In-library filename | 5C6D1E2F-3A4B-4C5D-8E9F-0A1B2C3D4E5F.heic |
| UUID | 1A2B3C4D-5E6F-4A8B-9C0D-1E2F3A4B5C6D |
| Title | Sunset over the bay |
| Caption | Golden hour at the marina |
| Taken | 20 April 2026, 18:32:07 |
| Modified | 2 May 2026, 09:15:00 |
| Favorite | yes |
| Edited | yes |
| Keywords | Travel, Vacation, Beach |
| Albums | Trips ▸ 2026 ▸ Iceland, and Best of 2026 |
| People | Katie, Suzy |
| Labels | Beach, Sunset, Sky |
| Detected text | PIER 39 |
| Camera | Apple iPhone 15 Pro |
| Place | Marina Green, 3950 Scott St, San Francisco, California, United States, 94123 |
| Moment | Marina Green |
| AI caption | A sunset over water with boats |
| Size | 4032 × 3024 |
Where an example says (photo with no title) or similar, it was rendered against a second photo
carrying nothing but a filename (IMG_9876.JPG) and the same capture date.