Guides / Tutorials
Dog breed images in a Django app
By the Dog Image API team. Updated .
To show dog breed images in Django, run a management command once that downloads full-size PNGs into MEDIA_ROOT, record each file in a small model, and render the stored file in a template. Until a file exists, fall back to the free 512 pixel preview and its credit link.
What you are building
The images come from Dog Image API. They are AI-generated illustrations, one dog per set, shown from nine views on a transparent background. Full-size files are 2048 x 2048 PNGs and need a key and a plan. Previews are WebP files and need neither.
The plan for the app has three parts:
- A
DogImagemodel that remembers which breed, variant and view you stored. - A command,
fetch_dog_images, that talks to the API and writes files. - A view and template that prefer your stored file and fall back to the preview.
This was checked on Django 6.1 with requests installed. Install the library with pip install requests and export the key in the shell that runs the command:
export DOG_IMAGE_API_KEY="paste-your-key-here"
Settings
Add the app to INSTALLED_APPS and point Django at a media folder.
# settings.py
INSTALLED_APPS += ["dogs"]
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
Do not read the key in settings.py. Only the command needs it, so only the command reads it.
The model
One row per stored image is enough. The unique constraint keeps you from saving the same file twice.
# dogs/models.py
from django.db import models
class DogImage(models.Model):
breed = models.SlugField()
variant = models.SlugField(default="default")
view = models.SlugField()
file = models.FileField(upload_to="dogs/")
class Meta:
constraints = [
models.UniqueConstraint(
fields=["breed", "variant", "view"], name="one_file_per_image"
)
]
Run python manage.py makemigrations dogs and python manage.py migrate. A FileField avoids the Pillow requirement that ImageField brings.
The management command
Create dogs/management/commands/fetch_dog_images.py, and add empty __init__.py files in management and commands.
import os
import time
import requests
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand, CommandError
from dogs.models import DogImage
BASE = "https://dogimage.dev"
class QuotaUsedUp(Exception):
pass
def error_text(resp):
try:
err = resp.json()["error"]
return f"{resp.status_code} {err['code']}: {err['message']}"
except (ValueError, KeyError):
return f"{resp.status_code} {resp.reason}"
def fetch_full(key, breed, variant, view, attempts=4):
url = f"{BASE}/api/v1/images/{breed}/{variant}/{view}"
for attempt in range(1, attempts + 1):
resp = requests.get(
url,
params={"size": "full"},
headers={"Authorization": f"Bearer {key}"},
timeout=60,
)
if resp.status_code == 200:
return resp.content, resp.headers.get("X-Quota-Remaining")
if resp.status_code == 429:
raise QuotaUsedUp(error_text(resp))
if resp.status_code in (502, 503) and attempt < attempts:
time.sleep(2**attempt)
continue
raise CommandError(error_text(resp))
class Command(BaseCommand):
help = "Download full-size dog breed PNGs into MEDIA_ROOT, once each."
def add_arguments(self, parser):
parser.add_argument("breeds", nargs="+", help="Breed slugs, such as pug")
parser.add_argument("--views", nargs="+", default=["front", "left", "head"])
parser.add_argument("--variant", default="default")
def handle(self, *args, **options):
key = os.environ.get("DOG_IMAGE_API_KEY")
if not key:
raise CommandError("Set DOG_IMAGE_API_KEY in the environment.")
for breed in options["breeds"]:
for view in options["views"]:
variant = options["variant"]
if DogImage.objects.filter(breed=breed, variant=variant, view=view).exists():
self.stdout.write(f"skip {breed}/{variant}/{view} (already stored)")
continue
try:
data, remaining = fetch_full(key, breed, variant, view)
except QuotaUsedUp as err:
self.stderr.write(f"Stopping: {err}")
return
image = DogImage(breed=breed, variant=variant, view=view)
image.file.save(f"{breed}/{variant}/{view}.png", ContentFile(data))
self.stdout.write(f"saved {image.file.name}, remaining: {remaining}")
Run it with python manage.py fetch_dog_images pug beagle --views front left.
What the command does on errors
A 429 with the code quota_used_up means the monthly count is spent. The command prints the message and returns, so files saved so far stay in place and the exit code is clean. Retrying would not help.
A 502 (file_unavailable) or 503 (plan_check_failed) is retried after 2, 4 and 8 seconds. Every other failure, such as 401 for a bad key or 404 for a wrong slug, raises CommandError with the API's own message. The errors and quotas reference lists each code.
Watching your quota
Every full-size reply carries X-Quota-Limit, X-Quota-Used and X-Quota-Remaining. The command prints the remaining value after each save. On the All images plan that header reads unlimited, which is why the command prints it as text and never casts it to an integer.
The view and template
The view looks for a stored file. If it finds none, it builds the preview URL and the credit URL.
# dogs/views.py
from django.shortcuts import render
from .models import DogImage
BASE = "https://dogimage.dev"
def breed_detail(request, breed, view="front"):
stored = DogImage.objects.filter(breed=breed, variant="default", view=view).first()
return render(request, "dogs/breed_detail.html", {
"name": breed.replace("-", " ").title(),
"stored": stored,
"preview_url": f"{BASE}/api/v1/images/{breed}/default/{view}?size=512",
"credit_url": f"{BASE}/dogs/{breed}",
})
<h1>{{ name }}</h1>
{% if stored %}
<img src="{{ stored.file.url }}" alt="{{ name }}, AI-generated illustration" width="512" height="512">
{% else %}
<img src="{{ preview_url }}" alt="{{ name }}, AI-generated illustration" width="512" height="512">
<p><a href="{{ credit_url }}">{{ name }} image: Dog Image API</a></p>
{% endif %}
The preview URL answers with a redirect to a WebP file, and browsers follow it. Previews may be hot-linked, but only with the visible credit link, as the template shows. Your own stored PNGs need no credit under the full-size terms on the license page.
In development, add static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) to your URL patterns so /media/ is served. In production, let your web server or object storage serve that folder.
Choosing breeds
Look up slugs on the breed index or on a page such as /dogs/pug. Seven breeds have several colors. For those, default is the main set, and a color slug picks another one. Check pricing before a big run, because each distinct full-size image counts against your monthly plan.
Questions
Why store the PNGs in MEDIA_ROOT instead of calling the API from a view?
Full-size files are meant to be downloaded once and kept on your side, and the endpoint needs a key that must stay on the server. A stored file also loads faster and keeps working if you later change plans.
Can the template show a dog before I have downloaded it?
Yes. The 512 pixel preview needs no key and may be hot-linked. Show it with the visible credit link to Dog Image API next to the image, and switch to your stored file once the command has run.
Where should DOG_IMAGE_API_KEY live in a Django project?
In the process environment of the machine that runs the command, set by your shell, your deploy tool or a secrets file outside version control. Do not put it in settings.py, a template or any JavaScript.
What happens when I run the command twice?
It skips every image that already has a database row and a stored file, so it asks the API only for what is missing. The API also counts a distinct image once per month, so a repeat fetch would not cost more quota.
Next steps
- API docs: endpoints, keys, limits and errors.
- All dog breeds, each with nine views.
- Pricing for full-size transparent PNG files.
- A dog breed card with Tailwind CSS
- A dog breed gallery in SvelteKit
- A dog breed picker in Angular
- A dog breed selector in Vue 3