How Immich Handles Geospatial Data and Map-Based Querying: A Deep Dive into map.service.ts
Immich handles geospatial data through a two-layer architecture where MapService orchestrates user permissions and MapRepository executes optimized PostGIS queries against the asset_exif table, enabling both map marker retrieval and reverse geocoding.
Immich stores latitude and longitude metadata for every geotagged photo and video in the asset_exif table, powering an interactive map feature built on PostGIS extensions. The implementation centers on server/src/services/map.service.ts, which coordinates with server/src/repositories/map.repository.ts to deliver sub-second geospatial queries. This article explains exactly how Immich handles geospatial data and map-based querying, from partner album scoping to reverse geocoding algorithms.
Architecture Overview
Immich's geospatial stack consists of two coordinated layers. The Service layer (MapService) resolves user contexts—including partners and shared albums—while the Repository layer (MapRepository) constructs type-safe SQL via Kysely. All geographic data is stored in PostgreSQL with specialized indexes on the asset_exif, geodata_places, and naturalearth_countries tables.
Fetching Map Markers
The getMapMarkers method serves as the primary entry point for populating map interfaces with geotagged assets.
Service Layer Orchestration
In server/src/services/map.service.ts, the service assembles the complete scope of users and albums before delegating to the repository. It resolves partner relationships through getMyPartnerIds and collects shared album contexts via albumRepository.
async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
// 1️⃣ Build the list of user IDs we should query:
const userIds = [auth.user.id];
if (options.withPartners) {
// partners are resolved via utility → partnerRepository
const partnerIds = await getMyPartnerIds({ userId: auth.user.id, repository: this.partnerRepository });
userIds.push(...partnerIds);
}
// 2️⃣ If the caller requested assets from shared albums, collect those album IDs:
const albumIds: string[] = [];
if (options.withSharedAlbums) {
const [ownedAlbums, sharedAlbums] = await Promise.all([
this.albumRepository.getOwned(auth.user.id),
this.albumRepository.getShared(auth.user.id),
]);
albumIds.push(...ownedAlbums.map(a => a.id), ...sharedAlbums.map(a => a.id));
}
// 3️⃣ Delegate to the repository which builds the actual query.
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
}
Repository Query Construction
The MapRepository.getMapMarkers method in server/src/repositories/map.repository.ts performs the heavy lifting. It joins the asset table with asset_exif, ensuring only rows with non-null latitude and longitude are returned.
@getMapMarkers(ownerIds, albumIds, options) {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', (b) =>
b
.onRef('asset.id', '=', 'asset_exif.assetId')
.on('asset_exif.latitude', 'is not', null)
.on('asset_exif.longitude', 'is not', null),
)
.select([
'id',
'asset_exif.latitude as lat',
'asset_exif.longitude as lon',
'asset_exif.city',
'asset_exif.state',
'asset_exif.country',
])
// ── Visibility filter ────────────────────────────────────────
.$if(options.isArchived === true, (qb) =>
qb.where((eb) =>
eb.or([
eb('asset.visibility', '=', AssetVisibility.Timeline),
eb('asset.visibility', '=', AssetVisibility.Archive),
]),
),
)
.$if(options.isArchived !== true, (qb) =>
qb.where('asset.visibility', '=', AssetVisibility.Timeline),
)
// ── Additional optional filters ─────────────────────────────────
.$if(options.isFavorite !== undefined, (q) => q.where('isFavorite', '=', options.isFavorite!))
.$if(options.fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', options.fileCreatedAfter!))
.$if(options.fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', options.fileCreatedBefore!))
// ── Owner / Album scoping ────────────────────────────────────────
.where('deletedAt', 'is', null)
.where((eb) => {
const expr: Expression<SqlBool>[] = [];
if (ownerIds.length > 0) expr.push(eb('ownerId', 'in', ownerIds));
if (albumIds.length > 0) {
expr.push(
eb.exists((eb2) =>
eb2
.selectFrom('album_asset')
.whereRef('asset.id', '=', 'album_asset.assetId')
.where('album_asset.albumId', 'in', albumIds),
),
);
}
return eb.or(expr);
})
.orderBy('fileCreatedAt', 'desc')
.execute();
}
Key implementation details include:
- Geotag validation: The
innerJoinwithasset_exiffilters out assets lacking coordinates. - Visibility handling: Conditional
$ifblocks map theisArchivedflag to theAssetVisibilityenum. - Dynamic scoping: The query constructs
ownerId IN (...)andEXISTSsubqueries for album membership. - Result ordering: Assets are sorted by
fileCreatedAt DESCto display newest items first.
Reverse Geocoding Implementation
When users click map coordinates or view asset details, Immich converts latitude/longitude pairs into human-readable locations through MapService.reverseGeocode.
Nearest City Lookup
The repository first queries the geodata_places table using PostGIS earth distance functions. It uses earth_box to constrain the search to a configurable radius defined by reverseGeocodeMaxDistance in server/src/constants.ts, then orders results by spherical distance.
const response = await this.db
.selectFrom('geodata_places')
.selectAll()
.where(
sql`earth_box(ll_to_earth_public(${point.latitude}, ${point.longitude}), ${reverseGeocodeMaxDistance})`,
'@>',
sql`ll_to_earth_public(latitude, longitude)`,
)
.orderBy(
sql`(earth_distance(
ll_to_earth_public(${point.latitude}, ${point.longitude}),
ll_to_earth_public(latitude, longitude)
))`,
)
.limit(1)
.executeTakeFirst();
Country Boundary Fallback
If no populated place exists within the search radius, the system falls back to a point-in-polygon query against naturalearth_countries.
const ne_response = await this.db
.selectFrom('naturalearth_countries')
.selectAll()
.where('coordinates', '@>', sql<string>`point(${point.longitude}, ${point.latitude})`)
.limit(1)
.executeTakeFirst();
Both query paths produce a ReverseGeocodeResult containing country, state, and city fields. Country codes are resolved to English names using the i18n-iso-countries library, with the populated place query additionally returning state (admin1Name) information while the fallback provides only country data.
Geodata Initialization
Both geospatial datasets are imported during application startup via MapRepository.init. The geodata_places table is populated from the GeoNames cities500 dataset, while naturalearth_countries loads Natural Earth GeoJSON polygons. The repository creates GiST indexes on both tables to accelerate earth_box and point-in-polygon operations.
Practical Code Examples
Retrieving Map Markers
To fetch markers for a user including partner assets and favorites only:
// DTOs are defined in src/dtos/map.dto.ts
const auth: AuthDto = { user: { id: 'a1b2c3' } };
const options: MapMarkerDto = {
withPartners: true,
withSharedAlbums: false,
isFavorite: true,
};
const markers = await mapService.getMapMarkers(auth, options);
/*
markers: [
{
id: 'asset‑123',
lat: 37.7749,
lon: -122.4194,
city: 'San Francisco',
state: 'California',
country: 'United States',
},
…
]
*/
Converting Coordinates to Location Names
To reverse-geocode a specific coordinate pair:
const point: MapReverseGeocodeDto = { lat: 48.8584, lon: 2.2945 }; // Eiffel Tower
const [location] = await mapService.reverseGeocode(point);
console.log(location);
// → { country: 'France', state: 'Île‑de‑France', city: 'Paris' }
Summary
- Immich stores geospatial metadata in the
asset_exiftable, requiring non-null latitude and longitude for map inclusion. - The MapService handles user permission scopes—including partners and shared albums—before delegating to MapRepository.
- MapRepository constructs type-safe Kysely queries that join
assetwithasset_exifand apply visibility, favorite, and date filters. - Reverse geocoding uses PostGIS
earth_boxandearth_distancefunctions against thegeodata_placestable, with a fallback tonaturalearth_countriesfor remote areas. - Static geographic data is imported from GeoNames and Natural Earth datasets during startup, with GiST indexes ensuring sub-second query performance.
Frequently Asked Questions
How does Immich store geospatial data for photos?
Immich extracts latitude and longitude from EXIF metadata during asset upload and stores these values in the asset_exif table alongside city, state, and country fields. Only assets with non-null coordinates are considered for map markers.
What database functions does Immich use for geospatial queries?
The repository leverages PostGIS functions including ll_to_earth_public for coordinate transformation, earth_box for bounding-box filtering, earth_distance for spherical distance calculations, and the @> operator for point-in-polygon tests against country boundaries.
How does Immich handle reverse geocoding when no city is nearby?
When the nearest populated place query against geodata_places returns no results within reverseGeocodeMaxDistance, the system executes a point-in-polygon query against naturalearth_countries to determine the country name, ensuring coverage for remote locations.
Where does Immich source its geographic data?
Immich imports the cities500 dataset from GeoNames for populated places and Natural Earth GeoJSON data for country boundaries. These are loaded into PostgreSQL during initialization with GiST indexes created on the geographic columns for optimized query performance.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →