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

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, prepend and remove accept 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 osxphotos PhotoInfo names.
  • {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 with title,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, prepend and remove take a list, not just a single value.
  • Format codes work on every field, not only the numeric ones.
  • zfill and {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.