# How Immich Handles Geospatial Data and Map-Based Querying: A Deep Dive into map.service.ts

> Discover how Immich handles geospatial data and map-based querying via map.service.ts. Learn about its two-layer architecture, PostGIS integration, and optimized querying for map markers and reverse geocoding.

- Repository: [Immich/immich](https://github.com/immich-app/immich)
- Tags: deep-dive
- Published: 2026-02-27

---

**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`](https://github.com/immich-app/immich/blob/main/server/src/services/map.service.ts), which coordinates with [`server/src/repositories/map.repository.ts`](https://github.com/immich-app/immich/blob/main/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`](https://github.com/immich-app/immich/blob/main/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`.

```ts
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`](https://github.com/immich-app/immich/blob/main/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.

```ts
@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`](https://github.com/immich-app/immich/blob/main/server/src/constants.ts), then orders results by spherical distance.

```ts
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`.

```ts
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:

```ts
// 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:

```ts
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.