Extracting EXIF Metadata Server-Side with ExifTool
Run ExifTool through exiftool-vendored in Node, which keeps a pool of long-lived exiftool -stay_open processes so each read costs milliseconds rather than a Perl start-up; read only the tags you need (DateTimeOriginal, OffsetTimeOriginal, Make, Model, GPSLatitude, GPSLongitude, Orientation, ImageWidth, ImageHeight, Duration), normalise them into typed columns — a UTC timestamp, decimal coordinates, integer dimensions — and store those in your metadata table. Then produce the public derivative with metadata stripped (-all= or Sharp’s default), so location data never leaves your storage unless the user opted in.
EXIF, XMP, IPTC and QuickTime metadata tell you when and where a photo or video was taken, on what device, and how it should be rotated. That powers sorting by capture date, map views and deduplication — and it is also personal data that leaks home addresses when published. ExifTool reads more formats and tag variants than any other library, including HEIC, RAW and phone video. This page belongs to metadata indexing and search in backend validation and cloud storage architecture; the table it fills is described in how to index file metadata in PostgreSQL.
When to use this approach
- Users upload photos or phone videos and you sort, group or map them by capture time or place.
- You must remove location data before publishing images.
- Your formats go beyond JPEG — HEIC, DNG, MOV, MP4 — where lighter EXIF libraries fall short.
Prerequisites
- Node 20+ with
exiftool-vendored28+ (bundles ExifTool for Linux, macOS and Windows; needs Perl on Linux). - The upload available as a local file in a processing worker.
- A metadata table with typed columns for the fields you keep.
- A privacy decision: which fields you store, which you show, and which you strip from derivatives.
Which tags you actually need
Implementation
import { ExifTool, ExifDateTime, type Tags } from "exiftool-vendored";
// One shared instance: a pool of stay_open processes. Close it on shutdown.
export const exiftool = new ExifTool({ maxProcs: 4, taskTimeoutMillis: 20_000, geolocation: false });
process.on("SIGTERM", () => exiftool.end());
export interface MediaMeta {
capturedAt: Date | null; capturedAtLocal: string | null; tzKnown: boolean;
lat: number | null; lon: number | null;
make: string | null; model: string | null;
orientation: number | null; width: number | null; height: number | null; durationSec: number | null;
}
function toDate(v: unknown): { utc: Date | null; local: string | null; tzKnown: boolean } {
if (v instanceof ExifDateTime) {
return { utc: v.toDate(), local: v.toISOString({ includeOffset: false }) ?? null, tzKnown: v.hasZone };
}
return { utc: null, local: null, tzKnown: false };
}
function clampCoord(v: unknown, max: number): number | null {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) && Math.abs(n) <= max && n !== 0 ? Math.round(n * 1e6) / 1e6 : null;
}
export async function readMeta(path: string): Promise<MediaMeta> {
const t: Tags = await exiftool.read(path, ["-fast"]); // -fast: stop before trailing data in large videos
const when = toDate(t.DateTimeOriginal ?? t.SubSecDateTimeOriginal ?? t.CreationDate ?? t.CreateDate);
return {
capturedAt: when.utc, capturedAtLocal: when.local, tzKnown: when.tzKnown,
lat: clampCoord(t.GPSLatitude, 90),
lon: clampCoord(t.GPSLongitude, 180),
make: t.Make?.trim() || null,
model: t.Model?.trim() || null,
orientation: typeof t.Orientation === "number" ? t.Orientation : null,
width: Number(t.ImageWidth ?? t.ExifImageWidth) || null,
height: Number(t.ImageHeight ?? t.ExifImageHeight) || null,
durationSec: typeof t.Duration === "number" ? t.Duration : null,
};
}
/** Write a copy with every metadata block removed except colour profile and orientation. */
export async function stripForPublic(src: string, dest: string): Promise<void> {
await exiftool.write(src, {}, ["-all=", "-tagsFromFile", "@", "-ICC_Profile", "-Orientation", "-o", dest]);
}
Storing the result:
INSERT INTO media_metadata (upload_id, captured_at, captured_local, tz_known, geo, make, model, orientation, width, height, duration_s)
VALUES ($1, $2, $3, $4,
CASE WHEN $5::float8 IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint($6, $5), 4326)::geography END,
$7, $8, $9, $10, $11, $12)
ON CONFLICT (upload_id) DO UPDATE SET captured_at = EXCLUDED.captured_at, geo = EXCLUDED.geo;
Line-by-line on the decisions that matter
- A shared, pooled instance. Starting Perl and ExifTool costs 100–300 ms.
exiftool-vendoredkeeps processes open and sends commands over stdin, so the per-file cost drops to a few milliseconds. Create one instance per worker and end it on shutdown, or processes linger. geolocation: false. Recent versions can look up city names from coordinates using a bundled database. Useful, but it adds memory and returns place names you may not want to store; enable it deliberately.- Date handling with offsets.
DateTimeOriginalhas no time zone;OffsetTimeOriginal(added in EXIF 2.31) does, and many phones write it.ExifDateTime.hasZonetells you whether the UTC value is real or assumes the server’s zone. Store both the local wall-clock time and atz_knownflag, so “photos taken in the evening” works even when UTC is unknown. - Rejecting
0,0and out-of-range coordinates. Cameras without a fix sometimes write zeros; “Null Island” in the Gulf of Guinea is not where your user was. Treat it as no location. -fastfor videos. Without it, ExifTool may scan to the end of multi-gigabyte files to find trailing metadata. For the tags above, the header is enough.- Stripping with an allow-list.
-all=removes everything;-tagsFromFile @ -ICC_Profile -Orientationcopies back only the colour profile (so colours stay right) and orientation (so the image stays upright). Everything else — GPS, serial numbers, thumbnails with original crops — is gone.
Where extraction and stripping happen
Privacy and retention
Location and device serial numbers are personal data under GDPR and similar laws. Store coordinates only if a feature uses them, document the purpose, and delete them with the upload. Consider coarsening before storing — rounding to two decimal places (about a kilometre) is enough for “photos near Paris” and much less sensitive than a doorstep. Let users see and remove the location attached to their uploads; a map feature is welcome, a surprise one is not.
Client-side stripping, as described in client-side media preprocessing, reduces what reaches your servers, but cannot be relied on: users upload through other clients, and some browsers pass files untouched. Always strip derivatives server-side regardless.
Running at volume
The ExifTool pool is the throughput knob. Each process handles one file at a time; maxProcs equal to the worker’s CPU count is a good start, since reads are mostly I/O and parsing. Batch-reading many files in one call is not faster than parallel single reads with the pool. For back-filling metadata across millions of existing objects, run a separate job that lists objects, downloads each to a temporary file (ExifTool needs a seekable file), reads it and deletes the copy — and use ranged downloads of the first few megabytes for JPEG and HEIC, where metadata sits at the start. Video metadata can sit at the end, so download videos fully or use -fast with a full file.
Handling files with no useful metadata
Many uploads have no capture metadata at all: screenshots, images saved from the web, files exported by editors that drop EXIF, videos re-encoded by messaging apps. Decide what “captured at” means for them before it becomes a sorting bug. A reasonable order is the metadata date, then the file’s lastModified as reported by the browser at upload (send it with the upload request, since the stored object’s timestamp is the upload time), then the upload time itself — and store which source was used, so the interface can say “uploaded on” rather than “taken on” when it only knows the former.
Do not fill gaps with guesses from the filename unless you mark them as such. IMG_20260714_183201.jpg usually does encode a capture time, but in the device’s local zone, and apps rename files freely. A captured_at_source column with values like exif, client_modified and upload keeps every downstream feature honest about how much it knows.
Configuration gotchas
Capture dates an hour or a day off. The timestamp had no offset and was interpreted in the server’s time zone. Use tzKnown to avoid showing false precision, and set the worker’s TZ=UTC so the fallback is at least consistent.
Error: spawn perl ENOENT in a slim container. exiftool-vendored on Linux runs the bundled Perl script with the system Perl. Install perl (Debian: perl-base is not enough for some modules; install perl).
Orientation applied twice. If you rotate pixels during resizing (Sharp’s .rotate()), set Orientation to 1 in the derivative instead of copying it back, or viewers rotate again.
HEIC files report width and height of a tile. Use ImageWidth/ImageHeight from the composite group that ExifTool computes rather than per-item tags; the library’s defaults already prefer the composite values.
Verification
exiftool -json -DateTimeOriginal -OffsetTimeOriginal -GPSLatitude -GPSLongitude -n -Orientation fixtures/iphone.heic
node -e 'import("./exif.js").then(async m=>{console.log(await m.readMeta("fixtures/iphone.heic"));await m.exiftool.end()})'
# After stripping: no GPS, no serial, ICC and Orientation kept.
exiftool -G1 -a -s public/iphone.jpg | grep -Ei 'gps|serial|orientation|icc_profile'
Frequently Asked Questions
Is ExifTool safe to run on untrusted files?
It is a mature parser and much less exposed than image decoders, but it still parses attacker-controlled bytes. Run it in the processing worker, with the pool’s task timeout, not in the API process.
Can I trust the capture date for anything important?
No. Metadata is written by the device and editable by anyone. Use it for sorting and display, not for evidence, billing or access decisions.
Why not use a JavaScript EXIF parser instead?
Pure JS parsers are fine for JPEG EXIF in the browser. Server-side, ExifTool’s coverage of HEIC, RAW, QuickTime and vendor maker notes saves you from a long tail of files with missing dates.