Zurück zum Blog

Währungsrechner in Django bauen: Komplettes Wechselkurs-API-Tutorial (2026)

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

Die meisten Django-Anwendungen, die Geld berühren, brauchen irgendwann Wechselkurse — sei es, um in drei Währungen zu fakturieren, Stripe-Auszahlungen zurück in eine Basiswährung zu verrechnen oder lokalisierte Preise an der Kasse darzustellen. Die richtige Antwort ist, eine Wechselkurs-API für Django anzuschließen und FX als gelöstes Problem zu behandeln. Die falschen Antworten lauten: Kurse in settings.py hartkodieren oder bei jeder Anfrage das Upstream-API rufen.

Dieser Leitfaden geht eine produktionsreife Integration von Anfang bis Ende durch. Wir bauen einen kleinen Währungsrechner in Django 5.x mit der Finexly API, dem Django-Cache-Framework für Hot Reads, einem Modell für Persistenz, einem Management-Command für geplanten Refresh, Decimal-Präzision (damit wir keine Cents an Fließkomma-Fehler verlieren), einem REST-Endpoint via Django Ninja, einer htmx-getriebenen UI und pytest mit responses für Tests, die das Netzwerk nie anfassen. Am Ende hast du eine Service-Klassen-Architektur, die in jedes Django-Projekt passt — SaaS-Billing, E-Commerce, Buchhaltung, überall wo Geld eine Grenze überquert.

Warum eine dedizierte Währungs-API hartkodierte Kurse schlägt

Kurse in settings.py hartkodieren ist die erste falsche Antwort. Die zweite ist, in jeder View das Upstream zu rufen. Eine richtig genutzte Währungs-API gibt dir vier Dinge, die hartkodierte Werte nicht können:

  • Frische auf Abruf. Kurse ändern sich kontinuierlich während der Markthandelszeit. Selbst ein 24 Stunden alter Kurs kann auf volatilen Paaren wie USD/JPY oder EUR/TRY 1–2 % laufen — genug, um die Marge eines SaaS-Tarifs auszuradieren.
  • Breite Abdeckung. Finexly deckt 170+ Währungen ab, einschließlich Emerging Markets und CBDC-Referenzkursen. Der EZB-Feed, mit dem viele offene Django-Pakete kommen, deckt etwa 32 Majors. Wenn ein einziger Nutzer in argentinischen Pesos oder türkischen Lira zahlt, zählt diese Lücke.
  • Ein einziger Vertrag. Eine JSON-Form über latest, historical und convert Endpoints hinweg, statt drei verschiedene Upstream-Feeds zusammenzukleben.
  • Vorhersagbares Kontingent. Ein dokumentiertes Rate Limit, das man durchdenken kann, statt „die EZB hat unsere IP gesperrt, weil wir zu oft gepollt haben".

Wenn du noch Optionen abwägst, gehen unser Vergleich kostenlose vs. bezahlte Währungs-API für 2026 und unsere Analyse ExchangeRate-API vs CurrencyLayer vs Finexly die Alternativen im Detail durch.

Was wir bauen

Ein kleines, meinungsstarkes Django-Modul namens fx, das fünf Dinge gut macht:

  1. Holt die neuesten Kurse für eine beliebige Basiswährung von Finexly über eine typisierte Service-Klasse.
  2. Cached Hot Reads im Django-Cache (Redis in Produktion, locmem in Dev).
  3. Persistiert jeden Fetch in einem ExchangeRate-Modell, damit Checkout nicht fällt, wenn das Upstream stottert.
  4. Aktualisiert Kurse zeitgesteuert via Management-Command + cron (oder Celery Beat).
  5. Exponiert einen JSON-Konvertierungs-Endpoint und ein htmx-getriebenes HTML-Formular.

Als wiederverwendbare App in jedes bestehende Django-Projekt einklinken.

Voraussetzungen

Du brauchst Python 3.11+, Django 5.0+ und einen Finexly-API-Key. Hol dir einen kostenlosen auf der Finexly-Anmeldeseite — der Free-Tier gibt 1.000 Requests pro Monat, mehr als genug für einen MVP, wenn du richtig cachst.

Schritt 1: Django-Projekt aufsetzen

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

Füge die neue App in INSTALLED_APPS in site/settings.py hinzu:

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

Wir verwenden httpx statt requests, weil es einen einzigen Client mit Timeouts, Retries via httpx.HTTPTransport(retries=...) und einen async Client liefert, den wir später austauschen können. Wir nutzen python-decouple für Umgebungsvariablen und django-ninja für den REST-Endpoint — beides minimal und nicht im Weg.

Schritt 2: Umgebungsvariablen konfigurieren

Leg eine .env im Projektroot an:

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

Dann verdrahte das in 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 Dev ohne verfügbares Redis tausch RedisCache gegen LocMemCache — der Rest des Codes ist es egal.

Ein TTL von 15 Minuten (900 Sekunden) ist ein vernünftiger Default für die meisten Endkunden-Apps. Für Hochfrequenz-Trading willst du Sub-Sekunden; für Buchhaltungsberichte reicht 6 Stunden. Unser Beitrag zu Caching- und Fehlerbehandlung-Best-Practices bespricht, wie man die richtige Zahl für seinen Use Case wählt.

Schritt 3: das ExchangeRate-Modell

Persistenz ist wichtig, weil sie dir einen Fallback gibt, wenn das Upstream nicht erreichbar ist, und einen Audit-Trail, wenn die Finanzabteilung fragt „welchen Kurs haben wir auf der Rechnung vom 3. April benutzt?". Leg fx/models.py an:

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}"

Zwei Designentscheidungen lohnt es zu erwähnen. Erstens, DecimalField, nicht FloatField — Fließkomma-Fehler summieren sich über Tausende von Konvertierungen, und du willst nicht der Engineer sein, der der Finanzabteilung erklärt, warum die Bücher pro Rechnung um 0,03 $ daneben liegen. Zehn Nachkommastellen sind für die Anzeige übertrieben, aber Standard für FX-Speicherung. Zweitens macht der zusammengesetzte Index (base, quote, -fetched_at) aus „gib mir den neuesten USD/EUR-Kurs" einen einzigen B-Tree-Seek statt eines Sorts.

Führ die Migration aus:

python manage.py makemigrations fx
python manage.py migrate

Schritt 4: die Service-Schicht

Die Service-Klasse ist der Ort, wo die ganze Logik lebt, die mit dem Upstream spricht. Views rufen niemals httpx direkt. Leg fx/services.py an:

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,
        )

Hier passiert viel, deshalb der Reihe nach. latest() ist der einzige öffentliche Lesepfad: Cache versuchen, auf Upstream zurückfallen, beim Rückweg persistieren. convert() behandelt den Edge Case gleiche Währung (multipliziere nicht unnötig mit 1 und riskiere Rundungsfehler) und quantisiert das Ergebnis auf zwei Nachkommastellen — das ist eine Darstellungsentscheidung; für die Speicherung mehr Präzision behalten. _fallback_from_db ist das Produktions-Sicherheitsnetz: wenn Finexly 503 zurückgibt, servieren wir den neuesten Kurs pro Quote aus unserer eigenen Datenbank. Checkout fällt nicht wegen eines Third-Party-Hick-Ups.

retries=2 auf httpx.HTTPTransport fängt die meisten transienten 502/503 automatisch ab. Das 8-Sekunden-Timeout ist konservativ — unter 5 Sekunden ist zu eng, wenn du Kontinente überquerst, über 10 Sekunden zerstört ein wackeliger Upstream dein Latenzbudget.

Schritt 5: eine Konvertierungs-View

Jetzt die Views. Leg fx/views.py an:

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)

Die Prüfung des HX-Request-Headers ist das htmx-Muster: dieselbe View, zwei Templates. Ein normaler POST gibt die ganze Seite zurück; ein htmx-getriebener POST gibt nur das Ergebnis-Fragment zurück, das htmx ins DOM einbaut. Der User sieht den umgerechneten Betrag an Ort und Stelle erscheinen, mit null von dir geschriebenem JavaScript.

Schritt 6: htmx-Templates

Leg fx/templates/fx/convert.html an:

{% 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>

Und das 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 %}

Verdrahte in fx/urls.py:

from django.urls import path
from . import views

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

Und in site/urls.py:

from django.urls import include, path

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

Schritt 7: ein REST-Endpoint mit Django Ninja

Für die JSON-Konsumenten — deine Mobile-App, deinen Background-Worker, deine Frontend-SPA — exponiere einen typisierten Endpoint. Leg fx/api.py an:

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)

Füg ihn zu site/urls.py hinzu:

from fx.api import api

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

Jetzt liefert GET /api/convert?amount=100&from_=USD&to=EUR ein streng typisiertes JSON-Payload. Django Ninja generiert auf /api/docs umsonst ein OpenAPI-Schema — nützlich, wenn dein Mobile-Team anfängt Fragen zu stellen.

Schritt 8: geplanter Refresh via Management-Command

Für die meisten Apps ist das clevere Muster ein periodischer Hintergrund-Refresh, der den Cache aufwärmt, bevor Nutzer hereinkommen. Leg fx/management/commands/refresh_rates.py an:

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}"))

Du brauchst leere fx/management/__init__.py und fx/management/commands/__init__.py, damit Django den Command findet.

Mit cron planen (oder Celery Beat, falls du eh schon Celery fährst):

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

Alle 15 Minuten werden vier Basen aufgefrischt. Jeder Refresh ist ein Upstream-Aufruf — 4 Aufrufe × 96 (Intervalle pro Tag) = 384 Aufrufe/Tag, locker innerhalb des Finexly-Free-Tiers von 1.000/Monat, wenn du auf Stundenintervall reduzierst. Siehe die Seite Preise für die Kontingente höherer Tiers, wenn du Frische unter 15 Minuten brauchst.

Schritt 9: Tests, die das Netz nie anfassen

Der ganze Sinn der Service-Schicht ist, dass man sie mocken kann. Installier responses und pytest-django:

pip install pytest pytest-django responses

Leg pytest.ini an:

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

Dann 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")

Lauf:

pytest -q

Vier Tests, null Netzwerk-Calls, Sub-Sekunden-Laufzeit insgesamt. Das ist der ganze Punkt.

Produktionsnotizen

Ein paar Dinge, die Django-Apps in Produktion beißen, sobald FX live ist:

  • DB-Persistenz ist dein Fallback. Wenn du _persist überspringst, legt ein Upstream-Ausfall den Checkout lahm. Nicht überspringen.
  • Cache Stampede. Wenn 100 Requests gleichzeitig einen kalten Cache treffen, feuerst du 100 Upstream-Calls ab. Nutze django-rediss get_or_set mit Lock, oder akzeptier den Trade-off, wenn dein Traffic niedrig ist.
  • Währungsdrift am Wochenende. FX-Märkte schließen. Die meisten APIs (Finexly inklusive) servieren den Freitagsschluss übers ganze Wochenende; deine Kurse bewegen sich erst zum Montagsopen. Wenn du Wochenend-Transaktionen abrechnest, dokumentier die Policy.
  • Audit-Trail. Die ExchangeRate-Tabelle wächst schnell. Setz einen partial Index auf die letzten 7 Tage, oder rolle ältere Zeilen in eine monatliche Zusammenfassung, wenn Compliance es zulässt.

Wenn du mehr Tiefe brauchst, behandelt unser Leitfaden zu Caching und Fehlerbehandlung Stampede-Schutz, Circuit Breakers und Graceful Degradation im Detail.

Häufig gestellte Fragen

Welche Wechselkurs-API funktioniert am besten mit Django? Jede REST-API funktioniert, aber die Ergonomie unterscheidet sich. Finexly liefert eine flache {rates: {...}}-Form, die sauber auf ein Python-Dict mappt, unterstützt 170+ Währungen und liefert einen Free-Tier von 1.000 Calls/Monat — genug für einen MVP. Alternativen wie Fixer und Open Exchange Rates haben andere Kontingentstrukturen; unser Vergleichsbeitrag geht die Trade-offs durch.

Decimal oder Float für Wechselkurse in Django? Immer DecimalField zum Speichern und Decimal in der Geschäftslogik. Float führt Rundungsfehler ein, die sich über Tausende von Konvertierungen summieren; in Finanzcode werden diese Fehler irgendwann zu nutzersichtbaren Bugs. Die Performance-Kosten sind vernachlässigbar.

Wie oft Kurse aktualisieren? Hängt vom Use Case ab. Für endkundenorientierte Apps mit lokalisierten Preisen reichen 15-Minuten-Intervalle. Für Buchhaltungsberichte ist stündlich okay. Für Trading oder Echtzeit-Hedging willst du Sub-Sekunden-WebSocket-Feeds — siehe unseren REST-vs-WebSocket-Beitrag für dieses Muster.

Kann ich das mit Django REST Framework statt Django Ninja nutzen? Ja. Die Service-Schicht ist Framework-agnostisch. Ersetz fx/api.py durch eine DRF-APIView, die ExchangeRateService().convert(...) aufruft und das Ergebnis serialisiert. Modell, Service, Management-Command und Tests bleiben identisch.

Wie behandle ich Währungen, die nicht in der API-Antwort vorkommen? Zwei Muster. Entweder einen expliziten ExchangeRateError werfen und an den User durchreichen (sauberere UX), oder auf einen Cross-Rate via USD zurückfallen (toleranter, aber schwerer zu debuggen). Für die meisten Apps ist früh werfen die richtige Wahl.

Finexly kostenlos testen

Bereit, Echtzeit-Wechselkurse in dein Django-Projekt zu integrieren? Hol dir deinen kostenlosen Finexly-API-Key — keine Kreditkarte nötig. Starte mit 1.000 kostenlosen Requests pro Monat, Echtzeit-Kursen für 170+ Währungen und einem JSON-Vertrag, der sauber auf Python mappt. Skaliere bei Bedarf auf unseren Preisplänen.

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 →

Diesen Artikel teilen