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 innerJoin with asset_exif filters out assets lacking coordinates.
  • Visibility handling: Conditional $if blocks map the isArchived flag to the AssetVisibility enum.
  • Dynamic scoping: The query constructs ownerId IN (...) and EXISTS subqueries for album membership.
  • Result ordering: Assets are sorted by fileCreatedAt DESC to 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_exif table, 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 asset with asset_exif and apply visibility, favorite, and date filters.
  • Reverse geocoding uses PostGIS earth_box and earth_distance functions against the geodata_places table, with a fallback to naturalearth_countries for 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →