MetalAPI
XAU3,997.89−1.54%XAG53.936−1.51%XPT1,554.47−0.83%XPD1,047.03−0.44%XCU6.5080+2.29%WTI103.27+3.82%BRENT111.70−1.30%EUR1.120400−0.01%GBP1.337500−0.01%JPY0.0063761+0.14%CHF1.213445−0.25%TRY0.020125−0.12%BTC80,506.91−0.33%ETH2,341.51−0.61%
All articles
GoldPythonTutorial

Gold Price API in Python: Fetch, Cache and Plot Prices

Use a gold price API in Python: get live gold prices with requests, handle errors and retries, cache responses, and plot a pandas time series with matplotlib.

By MetalAPI Team··7 min read

Gold price API in Python: what you need

This tutorial shows how to use a gold price API in Python, from the first request to a cached client and a chart of the last ten months. It uses MetalAPI, a JSON REST API for precious metals, industrial metals, energy, currencies and crypto, together with three well-known libraries: requests for HTTP, pandas for the time series and matplotlib for the chart. Every snippet runs on Python 3.9 or newer.

Create a free account and create an API key under API keys in the dashboard. Keys start with mk_live_ and are shown only once, so copy yours straight into an environment variable. The Free plan needs no credit card and includes 100 requests a month with daily price updates, which is enough for everything in this post.

Setup
pip install requests pandas matplotlib

# macOS and Linux
export METALAPI_KEY="mk_live_your_key_here"

# Windows PowerShell
$env:METALAPI_KEY = "mk_live_your_key_here"

Read the key with os.environ["METALAPI_KEY"], which fails loudly when the variable is missing, instead of pasting it into a script. If you prefer a .env file, the python-dotenv package loads it with one call to load_dotenv(). Add .env to .gitignore so the key never reaches your repository.

How to get the gold price in Python

Every MetalAPI endpoint is a GET request under https://metalapi.com/api/v1. The key can go in an api_key query parameter, an Authorization: Bearer header or an X-API-Key header. Use a header: full URLs end up in logs, and some requests exceptions print the URL with its query string. The /latest endpoint returns current prices, base sets the currency and currencies limits the response to the symbols you need:

gold.py
import os

import requests

resp = requests.get(
    "https://metalapi.com/api/v1/latest",
    params={"base": "USD", "currencies": "XAU"},
    headers={"X-API-Key": os.environ["METALAPI_KEY"]},
    timeout=10,
)
data = resp.json()
if not data["success"]:
    raise SystemExit(f"MetalAPI error {data['error']['code']}: {data['error']['info']}")

price = data["rates"][data["base"] + "XAU"]
print(f"Gold: {price:,.2f} {data['base']} per troy ounce")

The response looks like this. The numbers are the example values used throughout the MetalAPI documentation, not live prices:

Example response
{
  "success": true,
  "timestamp": 1792834892,
  "date": "2026-10-24",
  "base": "USD",
  "rates": { "XAU": 0.0002501319, "USDXAU": 3997.89 },
  "rate_limit": { "limit": 100, "remaining": 99, "used": 1, "plan": "Free", "...": "..." }
}

With these values the script prints Gold: 3,997.89 USD per troy ounce. rates["USDXAU"] is the price of one troy ounce of gold in the base currency. rates["XAU"] is its inverse, ounces of gold per dollar, and reading it by mistake is the most common bug in gold price code. Building the key from data["base"] keeps the script correct when you switch to base=EUR (key EURXAU) or base=INR. Add unit=gram or unit=kilogram for prices per gram or kilogram. timestamp is the time of the price data in Unix seconds, not the time of your request.

Handling gold price API errors in Python

A failed call returns an HTTP error status and a JSON body with success: false and an error object that holds a numeric code, a type and an info message. Branch on the code, never on the message text. Only a few codes are worth retrying:

  • 104 (too_many_requests, HTTP 429): the per-minute limit. Wait for the number of seconds in the Retry-After header.
  • 500, 503 and network errors: retry with exponential backoff.
  • Everything else, such as 101 or 102 (missing or invalid key), 105 (monthly quota used up), 106 (endpoint not in your plan) and the 2xx validation errors: fix the request, key or plan instead.

This small module wraps that logic. It reuses one requests.Session, so connections stay open between calls, and raises an exception you can inspect:

metalapi.py
import os
import time

import requests

API_URL = "https://metalapi.com/api/v1"
session = requests.Session()
session.headers["X-API-Key"] = os.environ["METALAPI_KEY"]


class MetalAPIError(Exception):
    def __init__(self, code, type_, info, retry_after=None):
        super().__init__(f"MetalAPI {code} ({type_}): {info}")
        self.code = code
        self.type = type_
        self.retry_after = retry_after


def _get(path, params):
    r = session.get(f"{API_URL}/{path}", params=params, timeout=10)
    try:
        data = r.json()
    except ValueError:  # not JSON, e.g. an HTML error page from a proxy
        raise MetalAPIError(r.status_code, "http_error", r.reason) from None
    if not data.get("success"):
        err = data["error"]
        retry_after = r.headers.get("Retry-After")
        raise MetalAPIError(err["code"], err["type"], err["info"], int(retry_after) if retry_after else None)
    return data


def call(path, params=None, retries=3):
    """GET an endpoint. Retries rate limits, server errors and network failures only."""
    for attempt in range(retries + 1):
        try:
            return _get(path, params or {})
        except (MetalAPIError, requests.RequestException) as exc:
            code = getattr(exc, "code", None)  # None for network errors
            if attempt == retries or (code is not None and code != 104 and code < 500):
                raise
            time.sleep(getattr(exc, "retry_after", None) or 2**attempt)

Backoff waits 1, 2 and 4 seconds before giving up. Retrying costs nothing here, because requests rejected with 104 or a server error are not billed. Validation errors are billed, which is one more reason never to retry them in a loop. The error handling guide lists every code with its HTTP status.

Caching gold prices to save requests

Each plan refreshes prices at a fixed interval: once a day on Free, every 30 minutes on Lite, every 10 minutes on Standard and every 60 seconds on Advanced and Pro. The updates fall on multiples of that interval counted from 00:00 UTC, so asking again before the next one returns the same numbers and spends quota for nothing. This cache keeps each response until the next update and serves the last good prices if the API cannot be reached:

prices.py
import math
import time

import requests

from metalapi import MetalAPIError, call

# Seconds. Free: 86_400, Lite: 3_600 (hourly fits its 1,000 requests), Standard: 600
UPDATE_INTERVAL = 600
_cache = {}  # (base, symbols, unit) -> (expires_at, data)


def latest(base="USD", symbols="XAU,XAG", unit="troy_oz"):
    key = (base, symbols, unit)
    now = time.time()
    hit = _cache.get(key)
    if hit and now < hit[0]:
        return hit[1]
    try:
        data = call("latest", {"base": base, "currencies": symbols, "unit": unit})
    except (MetalAPIError, requests.RequestException):
        if not hit:
            raise
        _cache[key] = (now + 60, hit[1])  # serve the last good prices, try again in a minute
        return hit[1]
    next_update = (math.floor(now / UPDATE_INTERVAL) + 1) * UPDATE_INTERVAL
    _cache[key] = (next_update + 5, data)  # expire a few seconds after the next update
    return data

latest("EUR", "XAU", "gram")["rates"]["EURXAU"] returns the euro price per gram. Ask for every symbol you need in one call: XAU,XAG,XPT,XPD costs one request, not four. Keep in mind that this cache lives in memory. A script started by cron begins with an empty cache every time, so there the schedule is the cache: run it at most once per update interval. To watch your quota, call /usage, which never counts towards it.

Loading a gold price time series into pandas

For history, the /timeframe endpoint (alias /timeseries) returns daily closing prices between start_date and end_date, keyed by date. One request covers up to 30 days on the Free plan and 365 days on paid plans, and a longer range fails with error 207. This function splits any range into allowed chunks and returns a pandas Series:

history.py
from datetime import date, timedelta

import pandas as pd

from metalapi import call


def gold_history(start, end, base="USD", max_days=30):  # max_days=365 on paid plans
    rates = {}
    chunk_start = start
    while chunk_start <= end:
        chunk_end = min(chunk_start + timedelta(days=max_days - 1), end)
        data = call("timeframe", {
            "start_date": chunk_start.isoformat(),
            "end_date": chunk_end.isoformat(),
            "currencies": "XAU",
            "base": base,
        })
        rates.update(data["rates"])
        chunk_start = chunk_end + timedelta(days=1)

    df = pd.DataFrame.from_dict(rates, orient="index")
    df.index = pd.to_datetime(df.index)
    return df[f"{base}XAU"].sort_index().rename(f"gold_{base.lower()}")


gold = gold_history(date(2026, 1, 1), date(2026, 10, 23))
gold.to_csv("gold_usd.csv")
print(f"{len(gold)} trading days, high {gold.max():,.2f}, low {gold.min():,.2f}")

On the Free plan these ten months take 10 requests. The Series has one row per trading day: weekends and market holidays are missing rather than filled, so expect fewer rows than calendar days. If you need a value for every day, gold.asfreq("D").ffill() carries Friday's close over the weekend. From here the usual pandas tools apply, such as gold.pct_change() for daily returns or gold.rolling(20).mean() for a moving average.

Past closing prices never change, so the CSV file is your cache for history: load it next time instead of requesting the same range again. For a full backfill since 2000 into a database, see Historical gold and silver prices via API.

Plotting the gold price with matplotlib

The chart reads the CSV file, so redrawing it costs no requests. It plots the daily close together with a 20-day moving average:

plot.py
import matplotlib.pyplot as plt
import pandas as pd

gold = pd.read_csv("gold_usd.csv", index_col=0, parse_dates=True)["gold_usd"]
sma20 = gold.rolling(20).mean()

fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(gold.index, gold, linewidth=1, label="Gold, USD per troy oz")
ax.plot(sma20.index, sma20, linewidth=2, label="20-day moving average")
ax.set_title(f"Gold price, {gold.index[0]:%d %b %Y} to {gold.index[-1]:%d %b %Y}")
ax.yaxis.set_major_formatter("{x:,.0f}")
ax.grid(alpha=0.3)
ax.legend()
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig("gold.png", dpi=150)

Because the index holds real dates, matplotlib spaces the points by time, and the moving average starts on the 20th trading day. To chart gold in another currency, call gold_history with base="EUR": the rate keys keep the same pattern, so the function works unchanged.

Next steps

You now have what most Python integrations need: an authenticated request, error handling with sensible retries, a cache that follows your plan's update interval, and history in pandas. Build and test it on the Free plan. When you need fresher prices, choose a plan by update interval and volume: a job that runs every 10 minutes uses about 4,500 requests a month, which fits Standard's 10,000. Compare the plans on the pricing page.

If the prices end up on a website, Gold price API in JavaScript shows how to serve them from a Node.js route without exposing your key.

FAQ

Is there a free gold price API for Python?

Yes. MetalAPI's Free plan includes 100 requests a month with daily price updates and needs no credit card, and every snippet in this post works on it. Its latest prices are the previous day's close, and one /timeframe request covers up to 30 days. Free gold price API explains the free tier in more detail.

Why is the XAU rate such a small number?

rates["XAU"] is the inverse rate: troy ounces of gold per one unit of the base currency. The price is under the prefixed key, rates["USDXAU"] for base USD or rates["EURXAU"] for base EUR. With the example values, 1 ÷ 0.0002501319 gives 3997.89 again.

How do I get the gold price per gram in Python?

Add unit=gram to the request, for example call("latest", {"base": "EUR", "currencies": "XAU", "unit": "gram"}). rates["EURXAU"] is then the price in euros per gram, and the response includes "unit": "gram". Avoid unit=ounce: it is the avoirdupois ounce, not the troy ounce gold is priced in.

How far back does gold price history go?

Daily gold prices start in 2000, and every plan can query the full history. Plans only differ in how many days one /timeframe request may cover: 30 on Free and 365 on paid plans. With max_days=365, everything from 2000 to today takes 27 requests.

How often should a Python script call the API?

No more often than your plan updates prices: once a day on Free, after 00:00 UTC; hourly on Lite, because every 30 minutes would take about 1,490 requests a month against a quota of 1,000; every 10 minutes on Standard. Expect flat prices over the weekend, when spot gold does not trade from Friday 17:00 to Sunday 17:00 New York time.

Try it with your own data

Create a free account and make your first request in under two minutes. 100 requests a month, no credit card.

Keep reading