Update: 2026-10-27 – Script version 1.1.0. If you use the original script, please replace it. What changed and why:
- File modification times are kept. The text always promised this, the script did not deliver:
-Pwas only mentioned in a comment. It is now passed to exiftool. - Keywords are written as a list. Version 1.0.0 stored “a, b, c” as one single keyword. Lightroom, Google and image databases now see each keyword on its own, in both IPTC and XMP.
- One exiftool call per folder. exiftool now reads the date from the file name itself, so a folder of 300 images starts exiftool once instead of 300 times. The new
-NoDateFromFilenameswitch replaces commenting out lines. - Runs on Windows. The script no longer goes through
bash, so it works wherever PowerShell 7 and exiftool run, and file names with quotes or$are safe. - Smaller fixes. A folder with a single JPG no longer crashes the script,
.JPGin capitals is included, and values with quotes,$or backslashes survive the self-update unchanged.

The Problem
As a photographer, properly tagging images with metadata is essential for copyright protection, SEO, and organization. Adobe Lightroom handles this well for new exports, but I ran into a gap: what about the hundreds of gallery images I’d already exported? I had some basic metadata exported but it was only containing mainly my name and the website URL.
Lightroom’s metadata tools work great during the export process, but retroactively applying consistent metadata to existing JPGs isn’t straightforward. I needed a solution that could batch-process entire directories of already-exported images with proper IPTC/EXIF data including creator info, copyright notices, licensing terms, and event-specific details without touching the file date (last modified date).
Also note the special URL for licensing information on my website which is referenced in the metadata as well.
This information is not only helpful for website visitors who want to license my images and for enforcing my copyright when someone uses them without permission, but it also helps search engines understand what is shown in the image and how licensing is regulated. Google actually shows some of this information in their image search. My galleries now run on my own site and publish the same credit and licensing details as structured data, and the embedded metadata keeps them attached to every downloaded file.
The Solution
I built a PowerShell script that wraps the excellent exiftool by Phil Harvey to apply comprehensive metadata to all JPGs in a directory. It handles both fixed metadata (creator info, copyright, URLs) that stays consistent across all my work, and variable metadata (title, description, keywords, location) that changes per shoot.
A key feature: the script derives the value for the “DateTimeOriginal” timestamp from filenames following the “YYYYMMDD-HHMMSS*” or “YYYYMMDD_HHMMSS*” pattern. An example file name is “20251217-190308_tu_orchester_muth_8823.jpg“. This preserves accurate capture times even when the original EXIF data is missing or incorrect.
The script uses PowerShell 7, which runs on Windows, macOS and Linux.
Handling the volatile parts
Via an Alfred-Automation on macOS I copy this file to all my exported JPG-directories that will be uploaded to my gallery in the next step.
Title, Description, Keywords, City, Country and Location are (potentially) different for each shoot. Keywords are separated by commas. The script has default values but also asks at runtime. After entering new values (or ENTER to keep the old ones) the script modifies itself, saving these as the new defaults. This way, I have to enter them only once for a given shoot and keep my metadata consistent, even if I export images again or add new ones. This .ps1 file is also backed up for later usage if needed.
The $FixedMetadata section contains your permanent branding - artist name, copyright notice, website URLs, and licensing info. Set this once in the script and forget it.
Requirements
PowerShell 7 (
pwshon macOS and Linux)exiftool installed and available in PATH
The Workflow
Export images from Lightroom using my standard naming convention (
YYYYMMDD-HHMMSS_shoot_sequence.jpg). If you do not use this naming convention, run the script with-NoDateFromFilename.Navigate to the export folder
Run
pwsh ./_apply-metadata.ps1and provide the values for the current shootThe script applies all metadata fields to every JPG in the folder in one exiftool run and preserves the file modification times
The script provides clear feedback - showing each processed file, success/error counts, and a summary of applied metadata.
Embed your Metadata, baby! Why This Matters
Embedded metadata travels with your images. When clients, editors, or anyone downloads your photos, your copyright and contact information remains attached. No separate sidecar files, no external databases - just properly tagged images ready for distribution.
For photographers managing large archives of previously exported work, this script bridges the gap that Lightroom leaves open.
One (possible) Caveat
The script overwrites the existing exported files. If you want to keep the originals, remove the -overwrite_original parameter from the script. exiftool then keeps a copy of each file with _original appended to its name.
The Script
# Apply-Metadata
<#
.SYNOPSIS
Applies creator, copyright, licensing and shoot-specific metadata
to all JPGs in a directory using exiftool.
.DESCRIPTION
This script
1. Asks for the shoot-specific values (title, description,
keywords, location) and saves them as new defaults in itself
2. Writes them together with the fixed creator/copyright values
to all *.jpg/*.jpeg files in the directory with one exiftool call
3. Derives DateTimeOriginal from file names starting with
YYYYMMDD-HHMMSS or YYYYMMDD_HHMMSS
4. Keeps the file modification time
This software is provided 'as is', without any warranties
or representations of any kind, whether express or implied,
including but not limited to warranties of merchantability,
fitness for a particular purpose, or non-infringement.
.CHANGELOG
1.1.0 - 2026-10-27 - ms - Keep file modification time (-P), write
keywords as a list, one exiftool call per
directory, no bash (runs on Windows), works
with a single file, safe self-update.
1.0.0 - 2026-01-22 - ms - Initial release.
.PARAMETER Directory
Directory with the exported JPGs. Defaults to the current directory.
.PARAMETER NoDateFromFilename
Do not derive DateTimeOriginal from the file name.
#>
#Requires -Version 7
param(
[string]$Directory = ".",
[switch]$NoDateFromFilename
)
$script:LogFilePath = Join-Path $PSScriptRoot "_apply-metadata.log"
$script:DebugLogging = $true
function Write-Log {
param (
[string]$Message,
[ValidateSet("DBG", "INF", "WRN", "ERR")]
[string]$Level = "INF",
[switch]$LogToFile = $false
)
$timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
$logMessage = "[$timestamp][$Level] $Message"
# Append to log file
if($LogToFile) {
Add-Content -Path $script:LogFilePath -Value $logMessage -Encoding UTF8
}
# Output to console with colors
switch ($Level) {
"DBG" { if($script:DebugLogging -eq $true) { Write-Host $logMessage -ForegroundColor DarkGray } }
"INF" { Write-Host $logMessage -ForegroundColor Cyan }
"WRN" { Write-Host $logMessage -ForegroundColor Yellow }
"ERR" { Write-Host $logMessage -ForegroundColor Red }
}
}
# ============================================================================
# EDIT THESE VALUES BEFORE RUNNING THE SCRIPT
# Keywords are separated by ", " and written as a list.
# ============================================================================
$ImageMetadata = [ordered]@{
# Image-specific metadata
"Title" = 'TU Orchester Vienna Concert at Muth Music Hall'
"Description" = 'Concert performance by the TU Orchester Vienna at the Muth Music Hall on December 17, 2025. Captured by Michael Seirer Photography.'
"Keywords" = 'TU Orchester Vienna, Muth Music Hall, Concert Photography, Michael Seirer, Vienna Events, Live Music, Orchestra Performance'
"City" = 'Vienna'
"Country" = 'Austria'
"Location" = 'Muth Music Hall'
}
# ============================================================================
# Fixed creator/copyright/licensing values (same for all images)
$FixedMetadata = [ordered]@{
# Creator info
"Artist" = "Michael Seirer"
"By-line" = "Michael Seirer"
"Creator" = "Michael Seirer"
"Credit" = "Michael Seirer Photography"
"Source" = "Seirer Photography"
# Copyright info
"Copyright" = "(c) Michael Seirer Photography"
"CopyrightNotice" = "(c) Michael Seirer Photography"
"Rights" = "(c) Michael Seirer Photography"
"Marked" = "True"
"Owner" = "Michael Seirer Photography"
# URLs
"URL" = "https://www.seirer-photography.com"
"WebStatement" = "https://www.seirer-photography.com/image-license"
"CreatorWorkURL" = "https://www.seirer-photography.com"
# Usage/License terms
"UsageTerms" = "All Rights Reserved"
# PLUS Licensor info
"LicensorName" = "Michael Seirer Photography"
"LicensorURL" = "https://www.seirer-photography.com/image-license"
"LicensorEmail" = "licensing@seirer-photography.com"
}
# Check if exiftool is available
if (-not (Get-Command "exiftool" -ErrorAction SilentlyContinue)) {
Write-Log "exiftool is not installed or not in PATH" -Level "ERR" -LogToFile
exit 1
}
Write-Log "Script started - Directory: $Directory" -Level "INF" -LogToFile
# ============================================================================
# HELPER FUNCTION: Update the script file with new default values
# This modifies the source script so that user-provided values become the new
# defaults for the next run. Values are stored as single-quoted strings, so
# quotes, $ and backslashes in a value survive unchanged.
# Parameters:
# - ScriptPath: Full path to this script file
# - Metadata: The $ImageMetadata hashtable with updated values
# ============================================================================
function Update-ScriptDefaults {
param(
[string]$ScriptPath,
[System.Collections.IDictionary]$Metadata
)
$content = Get-Content -Path $ScriptPath -Raw
# Update each field in the $ImageMetadata block
foreach ($key in $Metadata.Keys) {
# Single-quoted PowerShell string: only ' needs escaping, as ''
$quotedValue = "'" + ($Metadata[$key] -replace "'", "''") + "'"
# Match the pattern: "Key" = "..." or "Key" = '...' (first occurrence only)
$pattern = [regex]('(?m)^(\s*"' + [regex]::Escape($key) + '"\s*=\s*)(?:"[^"]*"|''(?:[^'']|'''')*'')')
$content = $pattern.Replace($content, { param($m) $m.Groups[1].Value + $quotedValue }, 1)
}
# Write updated content back to the script
Set-Content -Path $ScriptPath -Value $content -NoNewline
}
# ============================================================================
# HELPER FUNCTION: Prompt user for a metadata value with a default
# Displays the field name and current default value, allowing the user to
# either press ENTER to accept the default or type a new value to override it.
# Parameters:
# - FieldName: The label to display for the field (e.g., "Title", "City")
# - DefaultValue: The pre-configured default value from $ImageMetadata
# Returns: The default value if user presses ENTER, otherwise the user's input
# ============================================================================
function Get-MetadataValue {
param(
[string]$FieldName,
[string]$DefaultValue
)
# Display the field name and its current default value
Write-Host "`n${FieldName}: " -NoNewline -ForegroundColor Cyan
Write-Host $DefaultValue -ForegroundColor Yellow
# Prompt user for input - empty input means accept default
$userInput = Read-Host "Press ENTER to accept or type a new value"
# Return default if input is empty/whitespace, otherwise return user's input
if ([string]::IsNullOrWhiteSpace($userInput)) {
return $DefaultValue
}
return $userInput
}
# ============================================================================
# FIND JPG FILES
# @() keeps this an array even for a single file; -in is case-insensitive,
# so .JPG from a camera is included as well.
# ============================================================================
$jpgFiles = @(Get-ChildItem -Path $Directory -File | Where-Object { $_.Extension -in ".jpg", ".jpeg" })
if ($jpgFiles.Count -eq 0) {
Write-Log "No JPG files found in: $Directory" -Level "WRN" -LogToFile
exit 0
}
Write-Log "Found $($jpgFiles.Count) JPG file(s) in $Directory" -Level "INF" -LogToFile
# ============================================================================
# INTERACTIVE METADATA INPUT
# Prompt user for each editable metadata field, allowing them to review and
# optionally override the default values defined in $ImageMetadata above.
# ============================================================================
Write-Host "`n============================================" -ForegroundColor Cyan
Write-Host "Review/Edit Metadata Values" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
# Prompt for each field - user can press ENTER to keep default or type new value
foreach ($key in @($ImageMetadata.Keys)) {
$ImageMetadata[$key] = Get-MetadataValue -FieldName $key -DefaultValue $ImageMetadata[$key]
}
# ============================================================================
# UPDATE SCRIPT DEFAULTS
# Save the user's input as the new default values in this script file
# ============================================================================
Update-ScriptDefaults -ScriptPath $PSCommandPath -Metadata $ImageMetadata
Write-Log "Defaults updated in script for next run" -Level "DBG" -LogToFile
# Merge image metadata with fixed metadata
$metadata = [ordered]@{}
foreach ($key in $ImageMetadata.Keys) {
$metadata[$key] = $ImageMetadata[$key]
}
foreach ($key in $FixedMetadata.Keys) {
$metadata[$key] = $FixedMetadata[$key]
}
# ============================================================================
# CONFIRMATION
# Display a summary of all final metadata values and ask for confirmation
# before proceeding with the actual file processing.
# ============================================================================
Write-Host "`n============================================" -ForegroundColor Cyan
Write-Host "Final Metadata to Apply:" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
foreach ($key in $ImageMetadata.Keys) {
Write-Host (" {0,-12} {1}" -f "${key}:", $ImageMetadata[$key]) -ForegroundColor Yellow
}
# Final confirmation before processing files
$confirm = Read-Host "`nProceed with these values? (y/n)"
if ($confirm -ne "y") {
Write-Log "Operation aborted by user" -Level "WRN" -LogToFile
exit 0
}
# ============================================================================
# BUILD EXIFTOOL ARGUMENTS
# Arguments go directly to exiftool, no shell in between, so no quoting needed.
# -P keeps the file modification time
# -sep ", " splits list values like keywords into separate entries
# Keywords go to IPTC:Keywords and XMP-dc:Subject, which is where
# Lightroom and most image databases read them from
# ============================================================================
$exifArgs = @("-P", "-overwrite_original", "-sep", ", ")
foreach ($key in $metadata.Keys) {
if ($key -eq "Keywords") {
$exifArgs += "-IPTC:Keywords=$($metadata[$key])"
$exifArgs += "-XMP-dc:Subject=$($metadata[$key])"
} else {
$exifArgs += "-$key=$($metadata[$key])"
}
}
# DateTimeOriginal from the file name (YYYYMMDD-HHMMSS* or YYYYMMDD_HHMMSS*).
# exiftool evaluates this per file; names without the pattern keep their date.
if (-not $NoDateFromFilename) {
$exifArgs += '-DateTimeOriginal<${Filename;$_ = /^(\d{4})(\d{2})(\d{2})[-_](\d{2})(\d{2})(\d{2})/ ? "$1:$2:$3 $4:$5:$6" : undef}'
foreach ($file in $jpgFiles) {
if ($file.BaseName -notmatch '^\d{8}[-_]\d{6}') {
Write-Log " Could not extract date/time from filename: $($file.Name)" -Level "WRN" -LogToFile
}
}
}
# ============================================================================
# RUN EXIFTOOL ONCE FOR ALL FILES
# ============================================================================
Write-Log "Applying metadata..." -Level "INF" -LogToFile
$result = & exiftool @exifArgs -m -ext jpg -ext jpeg $Directory 2>&1
$exitCode = $LASTEXITCODE
$output = $result -join "`n"
foreach ($line in $result) {
Write-Log " $line" -Level "DBG" -LogToFile
}
$successCount = 0
$errorCount = 0
if ($output -match '(\d+) image files? updated') { $successCount = [int]$Matches[1] }
if ($output -match "(\d+) files? weren't updated due to errors") { $errorCount = [int]$Matches[1] }
if ($exitCode -ne 0 -and $errorCount -eq 0) {
Write-Log "exiftool exited with code ${exitCode}: $output" -Level "ERR" -LogToFile
$errorCount = $jpgFiles.Count - $successCount
}
Write-Log "========================================" -Level "INF" -LogToFile
Write-Log "Summary: Processed $($jpgFiles.Count) files - Success: $successCount, Errors: $errorCount" -Level "INF" -LogToFile
Write-Log "========================================" -Level "INF" -LogToFile
# Log what was applied
Write-Log "Metadata applied to all files:" -Level "INF" -LogToFile
foreach ($key in $metadata.Keys) {
Write-Log " $key = $($metadata[$key])" -Level "DBG" -LogToFile
}
if (-not $NoDateFromFilename) {
Write-Log " DateTimeOriginal = (extracted from filename)" -Level "DBG" -LogToFile
}
Write-Log "Script completed" -Level "INF" -LogToFile