CLI tool to download images and videos from Instagram posts
Project description
igdl
A minimal CLI tool that downloads all images and videos from an Instagram post to a local folder. Paste a link, get the files.
- Supports posts, reels, carousels, and IGTV
- Downloads all slides in a carousel in one command
- Optional login to bypass Instagram rate limits
- Configurable download directory and file naming
Requirements
- Python 3.8+
- macOS or Linux (Windows untested)
Install
The easiest way is with pipx, which handles all the Python environment setup automatically.
# Install pipx if you don't have it
brew install pipx
# Install igdl
pipx install git+https://github.com/viviciu/igdl
zsh users: add this to ~/.zshrc to prevent zsh from misreading ? in URLs:
alias igdl='noglob igdl'
Then reload: source ~/.zshrc
Manual install (without pipx)
Expand
git clone https://github.com/viviciu/igdl ~/igdl
cd ~/igdl
python3 -m venv .venv
.venv/bin/pip install -e .
mkdir -p ~/.local/bin
ln -sf ~/igdl/.venv/bin/igdl ~/.local/bin/igdl
Make sure ~/.local/bin is in your PATH (export PATH="$PATH:$HOME/.local/bin" in ~/.zshrc), then add the noglob alias above.
Setup
Set your download directory once:
igdl config --dir ~/Downloads/Instagram
This saves to ~/.igdl/config.json. Run igdl config with no flags to see the current setting.
Usage
# Download a post (images, videos, or carousels)
igdl https://www.instagram.com/p/SHORTCODE/
# Reels and IGTV work too
igdl https://www.instagram.com/reel/SHORTCODE/
igdl https://www.instagram.com/tv/SHORTCODE/
Instagram rate-limits anonymous requests. Log in once to avoid this:
igdl login your_username
Your session is saved to ~/.igdl/session_<username> and reused automatically on future downloads.
Customizing file naming
Files are named using instaloader's filename pattern. The default is {date_utc}_UTC (e.g. 2026-04-08_11-09-53_UTC_1.jpg).
To change it, edit igdl/downloader.py in the _loader() function and add a filename_pattern argument to the Instaloader() constructor:
L = instaloader.Instaloader(
filename_pattern="{owner_username}_{shortcode}", # ← change this
...
)
Available variables:
| Variable | Example |
|---|---|
{date_utc} |
2026-04-08_11-09-53 |
{shortcode} |
DW3lV16CO5K |
{owner_username} |
folchstudio |
{mediaid} |
3612345678901234567 |
{typename} |
GraphImage, GraphVideo |
Project structure
igdl/
pyproject.toml # package metadata and dependencies
igdl/
cli.py # argument parsing and command routing
config.py # read/write ~/.igdl/config.json
downloader.py # instaloader wrapper — core download logic
__init__.py
Runtime data (created automatically, not in the repo):
~/.igdl/
config.json # stores your configured download directory
session_<username> # saved Instagram session from igdl login
Dependencies
- instaloader — handles Instagram auth, carousels, and all media types
Why do we install this way? How does install work?
This section answers various questions I had along the way, to inform me about why certain dev decisions were made and in which scenarios I'd want to execute them again.
Is a venv created every time you use the tool?
No — it's created once when you install, and just sits at ~/igdl/.venv/ permanently. Every time you run igdl, it uses that same existing environment. Nothing is recreated.
Do most distributed CLI tools use a venv?
No. The more common approaches for tools meant to be shared are:
- pipx — the actual standard for Python CLI tools. It automatically creates an isolated environment per tool behind the scenes, so the user never thinks about it. pipx install igdl and you're done.
- brew — macOS users expect brew install sometool. Homebrew handles isolation itself.
- A single script — if the tool has no dependencies or uses only stdlib, just ship one .py file.
Venvs are really a development tool, not a distribution mechanism. The way we set it up is fine for personal use, but if you were seriously publishing this for others, you'd set it up for pipx instead.
We can't use a single script to run our CLI because we are using external dependencies.
Instaloader is an external dependency so a single script won't work on its own. You'd need the user to have it pre-installed, which isn't a great experience.
Where dependencies are declared: ~/igdl/pyproject.toml — the dependencies = ["instaloader"] line. That's the one source of truth. When pip installs the package, it reads that file and pulls instaloader in automatically.
Every file/folder in ~/igdl/:
~/igdl/
├── pyproject.toml # Package metadata: name, version, dependencies,
│ # and which function to run when you type igdl
├── README.md # Documentation (what you're editing)
├── .gitignore # Tells git which files/folders to never commit
│
├── igdl/ # The actual Python source code
│ ├── init.py # Makes this folder a Python package (can be empty)
│ ├── cli.py # Parses what you type and routes to the right action
│ ├── config.py # Reads/writes ~/.igdl/config.json
│ └── downloader.py # All the Instagram download logic
│
├── igdl.egg-info/ # ← Auto-generated by pip when you ran pip install -e .
│ # Bookkeeping metadata pip uses to track the install.
│ # You never touch it, git ignores it, safe to delete
│ # (it regenerates itself next time you install)
│
└── .venv/ # Your isolated Python environment — Python itself,
pip, and instaloader all live here. Never committed.
What is egg-info? pip's 'notes to self'
egg-info is just pip's scratch notes about your local install. The name comes from an old Python packaging format called "eggs" that predates the current standard — the name stuck even though eggs themselves are gone.
Whats __pycache__? Optimization.
init.py doesn't create pycache — Python itself does. Any time Python runs a .py file, it compiles it to bytecode (a faster, pre-parsed version) and caches it in pycache/. This happens automatically for every .py file that gets imported. It's just a performance optimization — Python skips re-parsing files it's already seen.
Do the Python files communicate with each other?
Yes, through import. Look at the top of cli.py:
from .config import get_download_dir, set_download_dir
from .downloader import download, login
The . means "from this same package." So when you run igdl, Python loads
cli.py, which pulls in specific functions from config.py and downloader.py.
They don't run in parallel or send messages — it's more like cli.py is in
charge and borrows tools from the other files when it needs them.
The flow when you run igdl :
cli.py ← entry point, reads your command → config.py ← "what's the download directory?" → downloader.py ← "go download this URL" → instaloader ← (external library, lives in .venv)
What's __init__.py?
init.py just tells Python "this folder is a package, treat the files inside as importable modules." Without it, the from .config import ... lines in cli.py wouldn't work. It's essentially a flag file.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file igdl-0.1.0.tar.gz.
File metadata
- Download URL: igdl-0.1.0.tar.gz
- Upload date:
- Size: 6.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
39e8230d53061d160ee0b6c49fcf27d73a6f7a7edb377ff280b7272bde37f8a6
|
|
| MD5 |
98e5caf42e364b90e71b53b9aa030f13
|
|
| BLAKE2b-256 |
5fa52a0d2a9293372f410c340fe4382730d5a5b6fad9fe87738f7515e0036065
|
File details
Details for the file igdl-0.1.0-py3-none-any.whl.
File metadata
- Download URL: igdl-0.1.0-py3-none-any.whl
- Upload date:
- Size: 7.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2c016720d8897aadcb698cba5a1486218c923088fb000a021d48740879bdfef4
|
|
| MD5 |
626d333595ae612a6c9fdf9f7e8dd0cb
|
|
| BLAKE2b-256 |
b53b6e0694efa82e164f25d20f3024a7c09e0e98f9a088e9949a1ad65b02f46c
|