# Display images in Dives
> Choose an image source for a Dive, and keep BLOB image galleries responsive with previews, batched queries, and a browser cache.
Show logos, product photos, or image galleries in a Dive. Pick an image source based on how many images you display and how often they change, then follow the steps for that source.

:::warning
Serving images through a database is less efficient than loading them from a web server or CDN. Dives block external image URLs, so image bytes reach the browser as SQL query results or as strings in the Dive source. Images loaded through queries skip the browser's HTTP cache, so each viewer fetches them again every time they open the Dive. Use images to support an analysis rather than as the main content of a Dive, and keep them few and small.
:::

## Prerequisites

- A [Dive](/key-tasks/dives/) you can edit through your AI agent or [as code](/key-tasks/dives/managing-dives-as-code/).
- For images in object storage or `BLOB` columns, a Dive that reads data with [`useSQLQuery`](/sql-reference/motherduck-sql-reference/dives/use-sql-query).

## Choose an image source

| Image source | Use it for | Trade-offs |
|---|---|---|
| [Data URI in the Dive source](#embed-small-static-images-in-the-dive-source) | A few small images that rarely change, such as logos and icons | Changing an image means editing the Dive. Base64 encoding adds about a third to each image's size. |
| [Files in object storage](#read-images-from-object-storage) | Image folders you add to over time, in a bucket every viewer can read | Each query reads the files from storage again. A private bucket needs a secret for every viewer. |
| [`BLOB` columns in MotherDuck](#load-blob-images-in-a-gallery) | Images that belong with your data, such as a gallery filtered by metadata | You generate previews and manage loading and caching in the Dive. |

## Embed small static images in the Dive source

Convert each image to a base64 data URI once, then keep it as a string constant in the Dive component. The image displays without a query or network request.

```bash
> printf 'data:image/svg+xml;base64,%s\n' "$(base64 < logo.svg | tr -d '\n')"
```

Copy the output into the Dive and use it as the image source:

```tsx
const COMPANY_LOGO = 'data:image/svg+xml;base64,<base64_data>';

export default function Dive() {
  return ![Company logo](COMPANY_LOGO);
}
```

Use the MIME type that matches each file, such as `image/png`. Every embedded image increases the size of the Dive source, which viewers download and your AI agent reads each time it edits the Dive. Reserve this approach for a handful of small images.

## Read images from object storage

Store images in Amazon S3 or another [cloud storage provider](/integrations/cloud-storage/), and read them with DuckDB's [`read_blob`](https://duckdb.org/docs/current/guides/file_formats/read_file#read_blob) function. The query reads the files each time it runs, so images you add to the folder appear in the Dive without a load step.

```sql
SELECT
    parse_filename(filename, true) AS image_name,
    'data:image/png;base64,' || base64(content) AS image_src
FROM read_blob('s3://<bucket_name>/<image_folder>/*.png')
ORDER BY image_name;
```

Render `image_src` with an image element. For larger images, select `content` instead and convert it with the Blob URL helper in [Cache browser image sources across filters](#4-cache-browser-image-sources-across-filters), which avoids the base64 overhead.

A Dive runs its queries as the person viewing it, or as the target service account for an [embedded Dive](/key-tasks/dives/embedding-dives/). Every viewer needs read access to the files:

- **Public bucket:** works for every viewer without a secret. Glob patterns such as `*.png` also need public list access to the bucket. Only store images you're willing to make public.
- **Private bucket:** [secrets](/sql-reference/motherduck-sql-reference/create-secret/) are scoped to the user who created them, so the query fails for viewers who don't have their own secret for the bucket. To share private images, load them into a table with a scheduled [Flight](/key-tasks/flights/), share the table, and follow [Load BLOB images in a gallery](#load-blob-images-in-a-gallery).

`read_blob` also reads files from HTTPS URLs. HTTPS paths don't support glob patterns, so pass a list of URLs instead, such as `read_blob(['https://<host>/logo.png', 'https://<host>/banner.png'])`.

## Load BLOB images in a gallery

Store images as `BLOB` values when they belong with your data, such as product photos filtered by metadata in the Dive. Load small previews once and reuse them across filters, and fetch the original image when a viewer opens a detail view.

Before you start, you need:

- Image metadata with a stable image ID and an original image stored as a `BLOB`.
- A data preparation process where you can generate previews and write them to MotherDuck.

The examples use JPEG images and an `image_id` column. Replace `<database_name>` with your database name, and adapt the table and column names to your schema. Declare the database or share in the Dive's [`REQUIRED_DATABASES`](/key-tasks/dives/#declaring-required-databases) array.

### 1. Measure the image loading path

Separate the time spent selecting image IDs, fetching image bytes, and displaying them in the browser. A fast metadata query doesn't establish that the images are ready.

Use [query profiling](/key-tasks/query-performance/) to inspect database work, and your browser's developer tools to inspect transfer and display time. Check the number of image queries and the bytes returned when you:

- Open the Dive for the first time.
- Select a filter you haven't visited.
- Return to a previous filter.
- Open an image in a detail view.

An image cache improves repeat visits. A filter that selects uncached images still needs to fetch them unless you preload those images.

### 2. Prepare small image BLOBs for the gallery

Choose preview dimensions based on the displayed card size. For example, a card with a 112px image area can use a preview with a 224px maximum edge to support higher pixel density. Preserve the aspect ratio and keep the original image for analysis and detail views.

Generate previews during data preparation. For example, use [Pillow](https://pillow.readthedocs.io/en/stable/) to resize JPEG images:

```python
import io
from PIL import Image, ImageOps

def make_preview(raw: bytes) -> bytes:
    with Image.open(io.BytesIO(raw)) as original:
        image = ImageOps.exif_transpose(original).convert("RGB")
        image.thumbnail((224, 224), Image.Resampling.LANCZOS)
        output = io.BytesIO()
        image.save(output, "JPEG", quality=75, optimize=True)
        return output.getvalue()
```

Store each preview under the same ID as its original, in a separate table:

```sql
CREATE TABLE "<database_name>"."main"."image_previews" (
    image_id BIGINT PRIMARY KEY,
    image BLOB
);
```

[Load the generated previews in bulk](/key-tasks/loading-data-into-motherduck/loading-data-md-python/), rather than inserting one row per request. If the Dive reads a share, include the preview table in that share and [publish the update](/sql-reference/motherduck-sql-reference/update-share/).

For selective original-image lookups, inspect the query plan and consider an [index on the image ID](/sql-reference/duckdb-sql-reference/duckdb-statements/create-index/). Recreate indexes after importing image data from Parquet; Parquet files don't preserve database indexes.

### 3. Batch and preload preview queries

Choose a loading strategy based on the size of the display set:

- **Small, known display set:** preload the union of images that the supported filters can display. This can be smaller than your complete image collection. Once loaded, filter queries can select IDs and render cached previews without another image query.
- **Large or open-ended gallery:** load a bounded page of previews, cache the results, and fetch additional batches as needed. Keep a limit on the bytes you preload.

For a small preview table, run this query once when the Dive opens:

```sql
SELECT image_id, image
FROM "<database_name>"."main"."image_previews"
ORDER BY image_id;
```

For a page of selected images, fetch their previews in one query:

```sql
SELECT image_id, image
FROM "<database_name>"."main"."image_previews"
WHERE image_id IN (101, 102, 103);
```

If multiple metadata queries contribute IDs to the gallery, wait until all selections are ready before requesting images. Use the query hook's [`enabled` option](/sql-reference/motherduck-sql-reference/dives/use-sql-query/#options) to skip requests until their inputs are ready. After a batch completes, fetch only IDs missing from your cache.

Show the page structure while previews load, and give the gallery its own loading and error states.

### 4. Cache browser image sources across filters

Read the image bytes and convert them to a browser Blob URL. The following helper handles raw bytes and a BLOB value with a `bytes` field:

```typescript
function blobToImageURL(value: unknown): string {
  const wrapped = value as { bytes?: unknown } | null;
  const bytes = value instanceof Uint8Array ? value
    : wrapped?.bytes instanceof Uint8Array ? wrapped.bytes
    : value instanceof ArrayBuffer ? new Uint8Array(value)
    : null;

  if (!bytes?.byteLength) {
    throw new Error('Unsupported or empty image BLOB');
  }

  return URL.createObjectURL(
    new Blob([new Uint8Array(bytes)], { type: 'image/jpeg' }),
  );
}
```

Call the helper in an effect or loader after the query succeeds, then store its result in a shared cache. Use the matching MIME type if your images use another format. Render the cached URL with an image element:

```tsx
![imageTitle](cachedSource)
```

A Blob URL references bytes held in the browser; it doesn't fetch the BLOB from MotherDuck. Creating one avoids building a base64 data URI in SQL, but reducing the number of queries and the image payload is what removes work from filter interactions.

Keep the cache in a provider or module scope that survives filter component remounts. A cache inside a component that unmounts on every tab change can't retain its images for the next visit. Keep image bytes and Blob URLs out of [`useDiveState`](/sql-reference/motherduck-sql-reference/dives/use-dive-state/), which stores shareable view state.

Set limits on the number of entries and their total byte size. Retain visible images, evict older inactive entries, and call [`URL.revokeObjectURL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static) when an evicted URL is no longer displayed. Compressed image bytes are only part of browser memory use; decoded pixels and query results also consume memory.

Include the dataset version, image variant, and image ID in each cache key. Invalidate the cache when the data or preview generation settings change. An in-memory cache lasts for the open Dive session; reloading starts a fresh session.

:::note
Sharing a database changes SQL access permissions; it doesn't create HTTP endpoints for the images or change the sandbox's image policy.
:::

### 5. Load original images on demand

Display the cached preview immediately when a viewer opens an image. Fetch the original BLOB for that image, then replace the preview when the original is ready. Cache the original separately so later detail views reuse it.

Keep original and preview cache keys distinct. Resizing a gallery preview shouldn't change the original used by an image analysis pipeline.

### 6. Verify filter and cache behavior

Test the loading strategy with first visits as well as repeat visits:

1. Open the Dive and verify the preview query returns valid image bytes. Measure its payload and loading time.
2. Select an unvisited tab or filter. If you preloaded its complete preview set, verify it triggers no additional image query. Metadata queries can still run.
3. Return to a previous selection and verify its cached image sources are reused.
4. Open an image and verify only its original is fetched. Open it again and verify the cache is used.
5. Exercise cache eviction, dataset updates, query errors, and a page reload. Verify loading indicators finish and visible images remain available.

If a gallery still pauses on each filter change, check whether the selection contains uncached IDs, whether a component remount cleared the cache, or whether overlapping queries request the same image bytes. For a preloaded gallery, verify the preview set covers every supported filter and sort order.

## Related content

- [Query performance](/key-tasks/query-performance/)
- [`useSQLQuery` hook](/sql-reference/motherduck-sql-reference/dives/use-sql-query/)
- [Sharing data](/key-tasks/sharing-data/sharing-overview/)
- [Amazon S3](/integrations/cloud-storage/amazon-s3/)
- [`CREATE SECRET`](/sql-reference/motherduck-sql-reference/create-secret/)
- [Managing Dives as code](/key-tasks/dives/managing-dives-as-code/)


---

## Docs feedback

MotherDuck accepts optional user-submitted feedback about this page at `GET https://motherduck.com/docs/api/feedback/agent`.
For agents and automated tools, feedback submission should be user-confirmed before sending.

URL-encode query parameter values and send a GET request:

```text
GET https://motherduck.com/docs/api/feedback/agent?page_path=%2Fkey-tasks%2Fdives%2Floading-images%2F&page_title=Display%20images%20in%20Dives&text=<url-encoded user feedback, max 2000 characters>
```

Optionally append `&source=<url-encoded interface identifier>` such as `claude.ai` or `chatgpt`.

`page_path` and `text` are required; `page_title` and `source` are optional. Responses: `200 {"feedback_id": "<uuid>"}`, `400` for malformed query parameters, and `429` when rate-limited.
