Tillbaka till bloggen

How to Build a Currency Converter in Django: Complete Exchange Rate API Tutorial (2026)

V
Vlado Grigirov
May 12, 2026
Django Python Currency API Exchange Rates Tutorial Finexly

Most Django apps that touch money will, sooner or later, need exchange rates — whether you're invoicing in three currencies, settling Stripe payouts back to a home base, or rendering localized prices on a checkout. The right answer is to plug in a Django currency exchange rate API and treat FX as a solved problem. The wrong answers are either hard-coding rates in settings.py or hitting the upstream on every request.

This guide walks through a production-grade integration end-to-end. We'll build a small currency converter in Django 5.x using the Finexly API, Django's cache framework for hot reads, a model for persistence, a management command for scheduled refresh, Decimal precision so we don't bleed cents to floating-point error, a REST endpoint via Django Ninja, an htmx-powered UI, and pytest with responses for tests that never touch the network. By the end you'll have a service-class architecture you can drop into any Django project — SaaS billing, e-commerce, accounting, anywhere money crosses a border.

Why a Dedicated Currency API Beats Hard-Coded Rates

Hard-coding rates in settings.py is the first wrong answer. The second wrong answer is reaching out to the upstream on every view. A dedicated currency API used correctly gives you four things hard-coded values can't:

  • Freshness on demand. Rates change continuously during weekday market hours. Even a 24-hour-stale rate can move 1–2% on volatile pairs like USD/JPY or EUR/TRY — enough to wipe out a SaaS plan's margin.
  • Coverage breadth. Finexly covers 170+ currencies including emerging-market and CBDC reference rates. The European Central Bank feed many open Django packages ship with covers roughly 32 majors. If a single user pays in Argentine pesos or Turkish lira, that gap matters.
  • A single contract. One JSON shape across latest, historical, and convert endpoints, instead of three different upstream feeds duct-taped together.
  • Predictable quota. A documented rate limit you can reason about, instead of "the ECB blocked our IP because we polled too aggressively."

If you're still weighing options, our free vs paid currency API comparison for 2026 and ExchangeRate-API vs CurrencyLayer vs Finexly write-ups go through the alternatives in detail.

What We're Building

A small, opinionated Django module called fx that does five things well:

  1. Fetches the latest rates for any base currency from Finexly, with a typed service class.
  2. Caches hot reads in Django's cache (Redis in production, locmem in dev).
  3. Persists every fetch into an ExchangeRate model so checkout never goes down if the upstream blips.
  4. Refreshes rates on a schedule via a management command + cron (or Celery beat).
  5. Exposes a JSON conversion endpoint and an htmx-powered HTML form.

Slug it into any existing Django project as a reusable app.

Prerequisites

You need Python 3.11+, Django 5.0+, and a Finexly API key. Grab a free one at the Finexly signup page — the free tier gives you 1,000 requests per month, which is more than enough for an MVP if you cache properly.

Step 1: Set Up the Django Project

python -m venv .venv
source .venv/bin/activate
pip install "django>=5.0,<6.0" httpx python-decouple django-ninja
django-admin startproject site .
python manage.py startapp fx

Add the new app to INSTALLED_APPS in site/settings.py:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "fx",
]

We're using httpx instead of requests because it gives us a single client with timeouts, retries via httpx.HTTPTransport(retries=...), and an async client we can swap in later. We're using python-decouple for environment variables and django-ninja for the REST endpoint — both are minimal and stay out of the way.

Step 2: Configure Environment Variables

Create a .env file in your project root:

DEBUG=True
SECRET_KEY=change-me-in-production
FINEXLY_API_KEY=your_finexly_api_key_here
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD

Then wire those into site/settings.py:

from decouple import config

SECRET_KEY = config("SECRET_KEY")
DEBUG = config("DEBUG", default=False, cast=bool)

FINEXLY_API_KEY = config("FINEXLY_API_KEY")
FINEXLY_BASE_URL = config("FINEXLY_BASE_URL", default="https://finexly.com/api/v1")
FX_CACHE_TTL = config("FX_CACHE_TTL", default=900, cast=int)
FX_DEFAULT_BASE = config("FX_DEFAULT_BASE", default="USD")

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"),
    }
}

In development with no Redis available, swap RedisCache for LocMemCache — the rest of the code doesn't care.

A 15-minute TTL (900 seconds) is a sensible default for most consumer-facing apps. For high-frequency trading you'd want sub-second; for accounting reports a 6-hour TTL is fine. Our currency API caching and error handling best practices post goes into how to pick the right number for your use case.

Step 3: The ExchangeRate Model

Persistence matters because it gives you a fallback when the upstream is unreachable, and an audit trail when finance asks "what rate did we use for that invoice on April 3rd?" Create fx/models.py:

from decimal import Decimal
from django.db import models


class ExchangeRate(models.Model):
    base = models.CharField(max_length=3, db_index=True)
    quote = models.CharField(max_length=3, db_index=True)
    rate = models.DecimalField(max_digits=20, decimal_places=10)
    fetched_at = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        indexes = [
            models.Index(fields=["base", "quote", "-fetched_at"]),
        ]
        constraints = [
            models.CheckConstraint(
                check=models.Q(rate__gt=Decimal("0")),
                name="rate_positive",
            ),
        ]

    def __str__(self) -> str:
        return f"{self.base}/{self.quote} = {self.rate} @ {self.fetched_at:%Y-%m-%d %H:%M}"

Two design decisions worth pointing out. First, DecimalField, not FloatField — floating-point error compounds across thousands of conversions, and you do not want to be the engineer explaining to finance why the books are off by $0.03 per invoice. Ten decimal places is overkill for display but standard for FX storage. Second, the (base, quote, -fetched_at) composite index makes "give me the most recent USD/EUR rate" a single B-tree seek instead of a sort.

Run the migration:

python manage.py makemigrations fx
python manage.py migrate

Step 4: The Service Layer

The service class is where all the upstream-talking logic lives. Views never call httpx directly. Create fx/services.py:

import logging
from decimal import Decimal
from typing import Mapping

import httpx
from django.conf import settings
from django.core.cache import cache

logger = logging.getLogger(__name__)


class ExchangeRateError(Exception):
    """Upstream fetch failed and no usable fallback exists."""


class ExchangeRateService:
    def __init__(
        self,
        api_key: str | None = None,
        base_url: str | None = None,
        cache_ttl: int | None = None,
        default_base: str | None = None,
    ) -> None:
        self.api_key = api_key or settings.FINEXLY_API_KEY
        self.base_url = base_url or settings.FINEXLY_BASE_URL
        self.cache_ttl = cache_ttl or settings.FX_CACHE_TTL
        self.default_base = default_base or settings.FX_DEFAULT_BASE

    def latest(self, base: str | None = None) -> Mapping[str, Decimal]:
        base = (base or self.default_base).upper()
        cache_key = f"fx:latest:{base}"

        cached = cache.get(cache_key)
        if cached is not None:
            return cached

        rates = self._fetch_latest(base)
        cache.set(cache_key, rates, self.cache_ttl)
        self._persist(base, rates)
        return rates

    def convert(self, amount: Decimal, frm: str, to: str) -> Decimal:
        frm, to = frm.upper(), to.upper()
        if frm == to:
            return amount
        rates = self.latest(frm)
        if to not in rates:
            raise ExchangeRateError(f"Currency {to} not found in {frm} rates")
        return (amount * rates[to]).quantize(Decimal("0.01"))

    def _fetch_latest(self, base: str) -> dict[str, Decimal]:
        url = f"{self.base_url}/latest"
        headers = {"Authorization": f"Bearer {self.api_key}"}
        transport = httpx.HTTPTransport(retries=2)
        try:
            with httpx.Client(transport=transport, timeout=8.0) as client:
                response = client.get(url, params={"base": base}, headers=headers)
                response.raise_for_status()
        except httpx.HTTPError as exc:
            logger.warning("Finexly fetch failed for %s: %s", base, exc)
            fallback = self._fallback_from_db(base)
            if fallback is not None:
                return fallback
            raise ExchangeRateError(str(exc)) from exc

        payload = response.json()
        rates = payload.get("rates")
        if not isinstance(rates, dict):
            raise ExchangeRateError("Malformed response: missing 'rates'")
        return {quote: Decimal(str(rate)) for quote, rate in rates.items()}

    def _fallback_from_db(self, base: str) -> dict[str, Decimal] | None:
        from .models import ExchangeRate

        recent = (
            ExchangeRate.objects.filter(base=base)
            .order_by("quote", "-fetched_at")
            .distinct("quote")
        )
        rates = {row.quote: row.rate for row in recent}
        return rates or None

    def _persist(self, base: str, rates: Mapping[str, Decimal]) -> None:
        from .models import ExchangeRate

        ExchangeRate.objects.bulk_create(
            [ExchangeRate(base=base, quote=q, rate=r) for q, r in rates.items()],
            batch_size=200,
        )

There's a lot going on here, so let's unpack it. The latest() method is the single public read path: try cache, fall back to upstream, persist on the way out. The convert() method handles the same-currency edge case (do not multiply by 1 needlessly and risk introducing rounding) and quantizes the result to two decimal places — that's a display choice; for storage, keep more precision. _fallback_from_db is the production safety net: if Finexly returns 503, we serve the most recent rate per quote from our own database. Checkout never goes down because of a third-party blip.

The retries=2 on httpx.HTTPTransport catches the vast majority of transient 502s and 503s automatically. The 8-second timeout is conservative — anything under 5 seconds is too tight if you ever cross continents, and anything over 10 means a flaky upstream wrecks your request budget.

Step 5: A Conversion View

Now the views. Create fx/views.py:

from decimal import Decimal, InvalidOperation

from django.http import HttpRequest, JsonResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from .services import ExchangeRateError, ExchangeRateService


@require_http_methods(["GET", "POST"])
def convert_view(request: HttpRequest):
    context: dict = {"result": None}
    if request.method == "POST":
        service = ExchangeRateService()
        try:
            amount = Decimal(request.POST.get("amount", "0"))
            frm = request.POST.get("from", "USD")
            to = request.POST.get("to", "EUR")
            converted = service.convert(amount, frm, to)
            context.update({"result": converted, "from": frm, "to": to, "amount": amount})
        except (InvalidOperation, ExchangeRateError) as exc:
            context["error"] = str(exc)

    template = "fx/_result.html" if request.headers.get("HX-Request") else "fx/convert.html"
    return render(request, template, context)

The HX-Request header check is the htmx pattern: same view, two templates. A normal POST returns the full page; an htmx-driven POST returns just the result fragment, which htmx swaps into the DOM. The user sees the converted amount appear in place with zero JavaScript that you wrote.

Step 6: htmx Templates

Create fx/templates/fx/convert.html:

{% load static %}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Currency Converter</title>
  <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 480px; margin: 4rem auto; }
    input, select, button { padding: 0.5rem; font-size: 1rem; }
    .result { margin-top: 1rem; font-size: 1.4rem; }
  </style>
</head>
<body>
  <h1>Currency Converter</h1>
  <form hx-post="{% url 'fx:convert' %}" hx-target="#result" hx-swap="innerHTML">
    {% csrf_token %}
    <input type="number" name="amount" step="0.01" required placeholder="Amount">
    <input type="text" name="from" maxlength="3" required placeholder="USD" value="USD">
    <input type="text" name="to" maxlength="3" required placeholder="EUR" value="EUR">
    <button type="submit">Convert</button>
  </form>
  <div id="result">{% include "fx/_result.html" %}</div>
</body>
</html>

And the partial fx/templates/fx/_result.html:

{% if error %}
  <p class="error">{{ error }}</p>
{% elif result %}
  <p class="result">{{ amount }} {{ from }} = <strong>{{ result }} {{ to }}</strong></p>
{% endif %}

Wire it into fx/urls.py:

from django.urls import path
from . import views

app_name = "fx"
urlpatterns = [
    path("convert/", views.convert_view, name="convert"),
]

And in site/urls.py:

from django.urls import include, path

urlpatterns = [
    path("", include("fx.urls")),
]

Step 7: A REST API Endpoint with Django Ninja

For the JSON consumers — your mobile app, your background worker, your frontend SPA — expose a typed endpoint. Create fx/api.py:

from decimal import Decimal

from ninja import NinjaAPI, Schema
from ninja.errors import HttpError

from .services import ExchangeRateError, ExchangeRateService

api = NinjaAPI(title="FX API")


class ConvertOut(Schema):
    amount: Decimal
    from_: str
    to: str
    rate: Decimal
    result: Decimal


@api.get("/convert", response=ConvertOut)
def convert(request, amount: Decimal, from_: str, to: str):
    service = ExchangeRateService()
    try:
        rates = service.latest(from_)
        rate = rates[to.upper()]
        result = service.convert(amount, from_, to)
    except (ExchangeRateError, KeyError) as exc:
        raise HttpError(502, f"FX unavailable: {exc}") from exc
    return ConvertOut(amount=amount, from_=from_.upper(), to=to.upper(), rate=rate, result=result)

Add it to site/urls.py:

from fx.api import api

urlpatterns = [
    path("", include("fx.urls")),
    path("api/", api.urls),
]

Now GET /api/convert?amount=100&from_=USD&to=EUR returns a strongly-typed JSON payload. Django Ninja generates an OpenAPI schema for free at /api/docs — useful when your mobile team starts asking questions.

Step 8: Scheduled Refresh via a Management Command

For most apps, the smart pattern is a periodic background refresh that warms the cache before users hit it. Create fx/management/commands/refresh_rates.py:

from django.core.management.base import BaseCommand

from fx.services import ExchangeRateError, ExchangeRateService


class Command(BaseCommand):
    help = "Refresh exchange rates from Finexly for the configured base currencies."

    def add_arguments(self, parser):
        parser.add_argument("--bases", nargs="+", default=["USD", "EUR", "GBP"])

    def handle(self, *args, **options):
        service = ExchangeRateService()
        for base in options["bases"]:
            try:
                rates = service.latest(base)
                self.stdout.write(self.style.SUCCESS(
                    f"{base}: refreshed {len(rates)} rates"
                ))
            except ExchangeRateError as exc:
                self.stderr.write(self.style.ERROR(f"{base}: {exc}"))

You'll need an empty fx/management/__init__.py and fx/management/commands/__init__.py for Django to find the command.

Schedule it with cron (or Celery beat if you already run Celery):

*/15 * * * * cd /app && /app/.venv/bin/python manage.py refresh_rates --bases USD EUR GBP JPY

Every 15 minutes, four bases get refreshed. Each refresh is one upstream API call — 4 calls × 96 (intervals per day) = 384 calls/day, well inside Finexly's free tier of 1,000/month if you scale down to hourly. See the pricing plans page for the higher-tier quotas if you need sub-15-minute freshness.

Step 9: Tests That Never Hit the Network

The whole point of the service layer is that you can mock it. Install responses and pytest-django:

pip install pytest pytest-django responses

Create pytest.ini:

[pytest]
DJANGO_SETTINGS_MODULE = site.settings
python_files = tests.py test_*.py *_tests.py

Then fx/tests/test_services.py:

from decimal import Decimal

import pytest
import responses
from django.core.cache import cache

from fx.services import ExchangeRateError, ExchangeRateService


@pytest.fixture(autouse=True)
def _clear_cache():
    cache.clear()
    yield
    cache.clear()


@responses.activate
def test_latest_returns_rates_from_upstream(settings):
    responses.add(
        responses.GET,
        f"{settings.FINEXLY_BASE_URL}/latest",
        json={"base": "USD", "rates": {"EUR": 0.92, "GBP": 0.79}},
        status=200,
    )
    service = ExchangeRateService()
    rates = service.latest("USD")
    assert rates["EUR"] == Decimal("0.92")
    assert rates["GBP"] == Decimal("0.79")


@responses.activate
def test_convert_quantizes_to_cents(settings):
    responses.add(
        responses.GET,
        f"{settings.FINEXLY_BASE_URL}/latest",
        json={"base": "USD", "rates": {"EUR": Decimal("0.9234")}},
        status=200,
    )
    service = ExchangeRateService()
    result = service.convert(Decimal("100"), "USD", "EUR")
    assert result == Decimal("92.34")


@responses.activate
def test_upstream_failure_raises_when_no_fallback(settings):
    responses.add(
        responses.GET,
        f"{settings.FINEXLY_BASE_URL}/latest",
        status=503,
    )
    service = ExchangeRateService()
    with pytest.raises(ExchangeRateError):
        service.latest("USD")


def test_same_currency_returns_input():
    service = ExchangeRateService()
    assert service.convert(Decimal("100"), "USD", "USD") == Decimal("100")

Run it:

pytest -q

Four tests, zero network calls, sub-second total runtime. That's the whole point.

Production Notes

A few things that bite Django apps in production once FX is live:

  • Database persistence is your fallback. If you skip the _persist step, an upstream outage takes checkout down. Don't skip it.
  • Cache stampede. If 100 requests hit a cold cache simultaneously, you'll fire 100 upstream calls. Use django-redis's get_or_set with a lock, or accept the tradeoff if your traffic is low.
  • Currency drift on weekends. FX markets close. Most APIs (Finexly included) serve Friday's close through the weekend; your rates won't move until Monday open. If you're settling weekend transactions, document the policy.
  • Audit trail. The ExchangeRate table grows fast. Add a partial index on the last 7 days, or roll older rows into a monthly summary if compliance permits.

If you need a deeper dive on the patterns above, our currency API caching and error handling guide covers stampede protection, circuit breakers, and graceful degradation in detail.

Frequently Asked Questions

Which currency exchange rate API works best with Django? Any REST API will work, but the ergonomics differ. Finexly returns a flat {rates: {...}} shape that maps cleanly to a Python dict, supports 170+ currencies, and ships with a free tier of 1,000 calls/month — enough for an MVP. Alternatives like Fixer and Open Exchange Rates have different quota structures; our comparison post walks through the tradeoffs.

Should I use Decimal or Float for currency rates in Django? Always DecimalField for storage and Decimal in your business logic. Float introduces rounding errors that compound across thousands of conversions; in financial code those errors eventually become user-visible bugs. The performance cost is negligible.

How often should I refresh rates? Depends on your use case. For consumer-facing apps showing localized prices, every 15 minutes is plenty. For accounting reports, hourly is fine. For trading or real-time hedging, you want sub-second WebSocket feeds — see our REST vs WebSocket post for that pattern.

Can I use this with Django REST Framework instead of Django Ninja? Yes. The service layer is framework-agnostic. Replace fx/api.py with a DRF APIView that calls ExchangeRateService().convert(...) and serializes the result. The model, service, management command, and tests all stay identical.

How do I handle currencies that aren't in the API response? Two patterns. Either raise an explicit ExchangeRateError and surface it to the user (cleaner UX), or fall back to a cross-rate via USD (more forgiving but harder to debug). For most apps, raising early is the right call.

Try Finexly Free

Ready to integrate real-time exchange rates into your Django project? Get your free Finexly API key — no credit card required. Start with 1,000 free requests per month, real-time rates for 170+ currencies, and a JSON contract that maps cleanly to Python. Scale up as your app grows on our pricing plans when you need it.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →

Dela den här artikeln