# Aiogram Files

> Use when uploading, downloading, or resending files with aiogram 3, choosing InputFile types, or reusing file IDs.

- Skill: `ballisarium/aiogram-files` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add ballisarium/aiogram-files`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ballisarium/aiogram-files/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ballisarium (https://skillmd.com/u/ballisarium)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ballisarium/aiogram-files

---


# aiogram files

Three ways to give Telegram a file, and one way that costs nothing.

```bash
python skills/using-aiogram/tools/get-type.py PhotoSize
python skills/using-aiogram/tools/get-method.py sendDocument
```

## when to use this skill

- sending a photo, document, video, audio, or voice message
- re-sending something the bot already sent
- downloading what a user uploaded
- handling files too large to hold in memory
- a `file_id` that works for one bot but not another

## the four sources

```python
from aiogram.types import BufferedInputFile, FSInputFile, URLInputFile

from_disk = FSInputFile("report.pdf", filename="Monthly report.pdf")
from_memory = BufferedInputFile(b"col1,col2\n1,2\n", filename="data.csv")
from_url = URLInputFile("https://example.com/image.jpg", filename="image.jpg")
by_id = "AgACAgIAAxkBAAExample"  # a plain str is treated as a file_id or an http URL

assert from_disk.filename == "Monthly report.pdf"
assert isinstance(by_id, str)
```

Verified constructors:

```text
FSInputFile(path, filename=None, chunk_size=65536)
BufferedInputFile(file: bytes, filename: str, chunk_size=65536)
URLInputFile(url, headers=None, filename=None, chunk_size=65536, timeout=30, bot=None)
```

| Source | Cost | Use when |
|---|---|---|
| `file_id` (`str`) | **no upload at all** | the bot has sent or received this file before |
| plain http(s) URL (`str`) | Telegram fetches it | a public URL Telegram can reach |
| `FSInputFile` | uploads, streamed from disk | a local file |
| `URLInputFile` | your bot downloads, then uploads | the URL needs headers/auth, or Telegram cannot reach it |
| `BufferedInputFile` | uploads from RAM | generated content |

`FSInputFile` streams in `chunk_size` blocks, so a 400 MB file does not become 400 MB of
memory. `BufferedInputFile` holds everything in RAM by definition — use it for generated
CSVs and images, not for large media.

`BufferedInputFile.from_file(path, filename=None, chunk_size=...)` also exists, but it
reads the whole file into memory; prefer `FSInputFile`.

## sending

```python
from aiogram import Router
from aiogram.types import BufferedInputFile, FSInputFile, Message
from aiogram.utils.formatting import Bold, Text

router = Router(name="send-files")


@router.message()
async def send(message: Message) -> None:
    await message.answer_document(FSInputFile("report.pdf"))
    await message.answer_photo(
        "https://example.com/chart.png",
        **Text("Chart for ", Bold("July")).as_caption_kwargs(),
    )
    await message.answer_document(
        BufferedInputFile(b"a,b\n1,2\n", filename="data.csv"),
        caption="Raw export",
    )
```

Captions are limited to 1024 characters after entity parsing; message text allows 4096.
Long explanations go in a following text message, not the caption.

## receiving and downloading

```python
from aiogram import Bot, F, Router
from aiogram.types import Message

router = Router(name="receive-files")


@router.message(F.document)
async def got_document(message: Message, bot: Bot) -> None:
    document = message.document
    if document is None:
        return

    await bot.download(
        document,
        destination=f"/tmp/{message.chat.id}-{message.message_id}-{document.file_unique_id}",
    )

    buffer = await bot.download(document)  # BinaryIO when destination is omitted
    if buffer is not None:
        buffer.read()


@router.message(F.photo)
async def got_photo(message: Message, bot: Bot) -> None:
    """Choose the largest pixel area explicitly."""
    if message.photo:
        photo = max(message.photo, key=lambda item: item.width * item.height)
        await bot.download(photo, destination=f"/tmp/{message.chat.id}-{message.message_id}.jpg")
```

Verified signatures:

```text
Bot.download(file: str | Downloadable, destination=None, timeout=30,
             chunk_size=65536, seek=True) -> BinaryIO | None
Bot.download_file(file_path: str | Path, destination=None, timeout=30,
                  chunk_size=65536, seek=True) -> BinaryIO | None
```

`download` accepts any `Downloadable` — `Document`, `PhotoSize`, `Video`, `Audio`,
`Voice`, `Sticker`, `VideoNote`, `Animation` — or a `file_id` string, and resolves the
file path itself. `download_file` takes an already-resolved `file_path` from `get_file`.

Omitting `destination` returns a `BinaryIO` holding the whole file in memory. Pass a path
or an open file object for anything large.

### the download limits

- **20 MB maximum cloud Bot API download.** Larger files raise `TelegramBadRequest: file is too big`.
  A self-hosted local Bot API server supports downloads without this size limit.
- The `file_path` from `get_file` is valid for **at least one hour**. Do not persist it —
  persist the `file_id` and call `get_file` again when needed.

## file_id and file_unique_id

This trips up almost everyone once:

| | `file_id` | `file_unique_id` |
|---|---|---|
| scope | **one bot** | global |
| can download | yes | **no** |
| stable | may change for the same file | stable |
| use for | re-sending, caching | deduplication, storage keys |

Consequences:

- A `file_id` captured by bot A is meaningless to bot B. Sharing ids across bots does not
  work. Token rotation for the same bot is not a documented reason to invalidate file ids.
- `file_unique_id` cannot be turned back into a file. It identifies, it does not fetch.
- Store both: `file_unique_id` as your key, `file_id` as the cheap re-send handle.

## caching to skip uploads

aiogram ships no file cache. Re-sending by `file_id` is dramatically cheaper than
re-uploading, so caching is worth the twenty lines:

```python
from aiogram import Bot
from aiogram.types import FSInputFile, InputFile, Message


class FileIdCache:
    """Upload once, re-send by id forever after. Ids are bot-scoped: key by bot id."""

    def __init__(self) -> None:
        self._ids: dict[tuple[int, str], str] = {}

    def get(self, bot: Bot, key: str) -> str | None:
        return self._ids.get((bot.id, key))

    def remember(self, bot: Bot, key: str, file_id: str) -> None:
        self._ids[(bot.id, key)] = file_id


cache = FileIdCache()


async def send_cached_photo(bot: Bot, chat_id: int, key: str, path: str) -> Message:
    cached = cache.get(bot, key)
    source: str | InputFile = cached if cached else FSInputFile(path)
    message = await bot.send_photo(chat_id=chat_id, photo=source)
    if not cached and message.photo:
        cache.remember(bot, key, message.photo[-1].file_id)
    return message
```

Use a real store (Redis, a table) rather than a dict once you have more than one process.
Key by bot identity and content version; invalidate a stale entry on a confirmed invalid
file-id error, rather than assuming all ids expired when the token was rotated.

## thumbnails and streaming

```python
from aiogram import Router
from aiogram.types import FSInputFile, Message

router = Router(name="video")


@router.message()
async def send_video(message: Message) -> None:
    await message.answer_video(
        FSInputFile("clip.mp4"),
        thumbnail=FSInputFile("thumb.jpg"),
        supports_streaming=True,
        width=1280,
        height=720,
        duration=42,
    )
```

`supports_streaming=True` lets clients play before the download finishes. Supplying
`width`, `height`, and `duration` supplies metadata; it does not guarantee that Telegram
will skip transcoding.

Thumbnails must be JPEG, under 200 kB, and at most 320×320.

## which send method

| Content | Method | Notes |
|---|---|---|
| photo | `answer_photo` | Telegram re-encodes and strips metadata |
| any file, unchanged | `answer_document` | use when the bytes must survive intact |
| video | `answer_video` | pass `supports_streaming` |
| looping muted video | `answer_animation` | GIF-style |
| music | `answer_audio` | shows a player, `performer` / `title` |
| voice message | `answer_voice` | OGG/Opus, MP3, or M4A |
| round video | `answer_video_note` | square, ≤60 s |
| sticker | `answer_sticker` | WEBP/TGS/WEBM |

Sending a photo as `answer_photo` compresses it. When the user needs the original file —
a scan, a design asset — send it as a document.

## checklist

- re-sends use `file_id`, never a re-upload
- `file_unique_id` used as the storage key, `file_id` as the send handle
- caches keyed by bot id
- large local files sent with `FSInputFile`, not `BufferedInputFile`
- downloads over ~20 MB expected to fail, and handled
- `file_path` from `get_file` never persisted
- choose photo size by pixel area when the largest size is required
- captions kept within 1024 characters

## see also

- `aiogram-media-groups` — albums
- `aiogram-errors` — `file is too big`, `wrong file identifier`
- `aiogram-streaming` — drafts and rich messages
- `skills/using-aiogram/references/telegram-quirks.md` — the full limit table

