DMI API Tutorial#

This tutorial gives an introduction on how to use the Danish Meteorological Institute’s (DMI) API to download meterological observation data (v2).

The tutorial uses the Python programming language and is in the format of a Jupyter Notebook. The notebook can be downloaded and run locally, allowing you to quickly get started downloading data. Part 1 of the tutorial provides some background basic information on how to work with the API, whereas a complete example is provided in Part 2.

If you’re new to the DMI observation data, I recommend that you check out some of the following links:

  1. Meteorological observations data

  2. Meteorological observations API

  3. Station list

  4. Station list explained

  5. FAQ

  6. Terms of use

  7. Operational status

  8. User creation


First, in order to retrieve data, it is necessary to create a user and obtain an API key. This API key grants permission to retrieve data and allows DMI to generate usage statistics.

A guide to creating a user profile and getting an API key can be found here.

api_key = 'xxxxxxxx-yyyy-zzzz-iiii-jjjjjjjjjjjj' # insert your own key between the '' signs
Hide code cell content
# Delete this cell if you run the notebook locally
import os
api_key = os.environ["DMI_API_KEY"]

An easy test to see if your API key works is to paste the following url into your browser, followed by a question mark and your api-key, e.g.: https://dmigw.govcloud.dk/v2/metObs/collections?api-key=xxxxxxxx-yyyy-zzzz-iiii-jjjjjjjjjjjj (the example API key error).

If you have obtained an API key and pasted it correctly, a page with text will be shown. If the API key does not work, the page will say “API key invalid”.


The following code blocks retrieve a list of all the DMI stations (both in Denmark and in Greenland) and plots them on a map using the Python package Folium.

Hide code cell content
import requests
import pandas as pd
r = requests.get('https://dmigw.govcloud.dk/v2/metObs/collections/station/items', params={'api-key': api_key})
stations = pd.json_normalize(r.json()['features'])
stations.columns = [c.replace('properties.', '').replace('geometry.', '') for c in stations.columns]

# Fileter out inactive stations
stations = stations[stations['status'] == 'Active']
# This line removes previous locations of the same station
# thus only the newest/current location is shown
stations = stations[stations['validTo'].isna()]
stations
type id type coordinates owner country anemometerHeight wmoCountryCode operationFrom parameterId ... type stationHeight regionId name wmoStationId operationTo updated stationId validTo status
2 Feature 19e64c5a-68ce-51f3-5e8f-d22fb54d3d24 Point [4.2719, 56.3442] Havne Kommuner mv DNK NaN 6080 2012-05-01T00:00:00Z [cloud_height, pressure, temp_dew, temp_dry, v... ... Synop 46.330 6 Harald B 06018 None None 06018 None Active
4 Feature d6a436c9-bd9e-2d22-dd3e-836e1f60b31d Point [8.6412, 56.93] DMI DNK 10.0 6080 2002-03-22T00:00:00Z [humidity, humidity_past1h, leav_hum_dur_past1... ... Synop 42.000 6 Silstrup 06019 None None 06019 None Active
7 Feature fcf41ab8-f0e3-0a0d-f98c-1c10c36f35a8 Point [4.7587, 55.5797] Havne Kommuner mv DNK NaN 6080 2012-05-01T00:00:00Z [cloud_height, humidity, pressure, pressure_at... ... Synop 55.470 6 Gorm C 06023 None None 06023 None Active
10 Feature 9c4e10c5-296f-caaf-d734-cbfda2150d1f Point [9.8505, 57.0963] Forsvaret DNK 10.0 6080 1953-01-01T00:00:00Z [cloud_cover, cloud_height, humidity, humidity... ... Synop 3.000 6 Flyvestation Ålborg 06030 None None 06030 None Active
13 Feature 8c416930-e59f-f17c-862e-9fc413461007 Point [9.9527, 57.1852] Synop - Århus Uni DNK 10.0 6080 2002-03-21T00:00:00Z [humidity, humidity_past1h, leav_hum_dur_past1... ... Synop 13.000 6 Tylstrup 06031 None None 06031 None Active
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
624 Feature c9cef7c6-1fec-94d4-f96c-d59d2112aa5c Point [-6.6818, 61.3957] Havne Kommuner mv FRO NaN 6080 1996-03-20T00:00:00Z [cloud_cover, cloud_height, humidity, humidity... ... Synop 89.600 6 Sudur 06009 None None 06009 None Active
628 Feature 66aca493-c1a2-4f85-4c3e-58c5879524f0 Point [-6.7644, 62.0232] Havne Kommuner mv FRO NaN 6080 1967-03-19T00:00:00Z [cloud_cover, cloud_height, humidity, humidity... ... Synop 54.240 6 Havn 06011 None None 06011 None Active
632 Feature 38e1ad01-03ed-6235-1e2e-3224039a093c Point [-6.3205, 62.3195] Havne Kommuner mv FRO NaN 6080 1999-05-14T00:00:00Z [cloud_cover, cloud_height, humidity, humidity... ... Synop 54.000 6 Fugloy 06012 None None 06012 None Active
635 Feature db8b6123-e688-bf23-92db-eb18c7366f4a Point [-6.5766, 62.2178] Havne Kommuner mv FRO NaN 6080 2009-05-18T00:00:00Z [cloud_cover, cloud_height, humidity, humidity... ... Synop 80.162 6 Klaksvik Heliport 06013 None None 06013 None Active
638 Feature 48df3aba-c1bd-4627-67b3-0070eb0ffcdd Point [-7.074, 62.2995] Havne Kommuner mv FRO NaN None 2022-05-25T00:00:00Z [humidity, humidity_past1h, precip_past10min, ... ... Synop 176.500 None Eidi None None None 06014 None Active

265 rows × 23 columns

Make this Notebook Trusted to load map: File -> Trust Notebook

Part 1: Retrieving data#

Part 1 of this tutorial will show how to request data and convert it to a table format. Part 2 will deal with how to request specific data and more advanced data handling.

First, the necessary libraries have to be imported:

import requests # library for making HTTP requests
import pandas as pd # library for data analysis
import datetime as dt # library for handling date and time objects

In the following code block, data is retrieved using the requests.get function. Further information on REST APIs and HTTP request methods can be found here.

DMI_URL = 'https://dmigw.govcloud.dk/v2/metObs/collections/observation/items'
r = requests.get(DMI_URL, params={'api-key': api_key}) # Issues a HTTP GET request
print(r)
<Response [200]>

The response status code indicates whether the request was successful or not. A 200 code means that the retrieval was successful.

Next, we extract the JSON file containing the data from the returned request object. JSON is a human-readable format for data exchange.

json = r.json()  # Extract JSON data
print(json.keys())  # Print the keys of the JSON dictionary
dict_keys(['type', 'features', 'timeStamp', 'numberReturned', 'links'])

When inspecting the json object, it can be noticed that the measurement data is contained within the features:
json['features'][:2]
[{'type': 'Feature',
  'id': '71088f65-8549-7ac6-8a8a-715784a97819',
  'geometry': {'type': 'Point', 'coordinates': [12.6455, 55.614]},
  'properties': {'parameterId': 'pressure',
   'created': '2025-08-12T20:11:14.877597Z',
   'value': 1020.2,
   'observed': '2016-09-20T21:40:00Z',
   'stationId': '06180'}},
 {'type': 'Feature',
  'id': '712f3af0-f96a-322a-142a-a7890ec505d1',
  'geometry': {'type': 'Point', 'coordinates': [12.149, 55.3955]},
  'properties': {'parameterId': 'temp_soil',
   'created': '2025-08-12T20:11:17.837691Z',
   'value': 15.7,
   'observed': '2016-09-20T21:40:00Z',
   'stationId': '06174'}}]

The JSON object can be converted to a convenient table (pandas DataFrame) using pd.json_normalize:

df = pd.json_normalize(json['features'])  # Convert JSON object to a Pandas DataFrame
df.head()  # Print the first five rows of the DataFrame
type id geometry.type geometry.coordinates properties.parameterId properties.created properties.value properties.observed properties.stationId
0 Feature 71088f65-8549-7ac6-8a8a-715784a97819 Point [12.6455, 55.614] pressure 2025-08-12T20:11:14.877597Z 1020.2 2016-09-20T21:40:00Z 06180
1 Feature 712f3af0-f96a-322a-142a-a7890ec505d1 Point [12.149, 55.3955] temp_soil 2025-08-12T20:11:17.837691Z 15.7 2016-09-20T21:40:00Z 06174
2 Feature 713f58fb-b831-969a-7779-bf9484e77d98 Point [11.6035, 55.7358] temp_dry 2025-08-12T20:11:16.628586Z 13.6 2016-09-20T21:40:00Z 06156
3 Feature 71b1f15b-4baf-7b8f-ff96-6cb55c265f78 Point [12.5263, 55.7664] wind_dir 2025-08-12T20:11:16.623808Z 0.0 2016-09-20T21:40:00Z 06181
4 Feature 7289beee-c82f-8d03-e3cf-85aa58884653 Point [11.3374, 55.7489] precip_dur_past10min 2025-08-12T20:11:15.785774Z 0.0 2016-09-20T21:40:00Z 05529

The timestamps strings can be converted to a datetime object using the pandas to_datetime function.

df['time'] = pd.to_datetime(df['properties.observed'])
df['time'].head()  # Print the first five timestamps
0   2016-09-20 21:40:00+00:00
1   2016-09-20 21:40:00+00:00
2   2016-09-20 21:40:00+00:00
3   2016-09-20 21:40:00+00:00
4   2016-09-20 21:40:00+00:00
Name: time, dtype: datetime64[ns, UTC]

Last, we will generate a list of all the available parameters:
parameter_ids = df['properties.parameterId'].unique()  # Generate a list of unique parameter ids
print(parameter_ids)  # Print all unique parameter ids
['pressure' 'temp_soil' 'temp_dry' 'wind_dir' 'precip_dur_past10min'
 'precip_past10min' 'temp_dew' 'wind_speed' 'humidity' 'wind_max'
 'pressure_at_sea' 'temp_grass' 'cloud_cover' 'sun_last10min_glob'
 'radia_glob' 'visibility' 'visib_mean_last10min' 'cloud_height' 'weather'
 'leav_hum_dur_past10min' 'wind_min']



Part 2: Requesting specific data#

The above example was a heavily simplied example to illustrate how the API can be accessed. For most applications you probably want to specify query criterias, such as:

  1. Meterological stations (e.g. 04320, 06074, etc.)

  2. Parameters (e.g. wind_speed, humidity, etc.)

  3. Time frame (to and from time)

  4. Limit (maximum number of observations)

Click the “View to show” button below to see a list of a all stations and parameters.

Hide code cell content
all_stations = [
    '04203', '04208', '04214', '04220', '04228', '04242', '04250',
    '04253', '04266', '04271', '04272', '04285', '04301', '04312',
    '04313', '04320', '04330', '04339', '04351', '04360', '04373',
    '04382', '04390', '05005', '05009', '05015', '05031', '05035',
    '05042', '05065', '05070', '05075', '05081', '05085', '05089',
    '05095', '05105', '05109', '05135', '05140', '05150', '05160',
    '05165', '05169', '05185', '05199', '05202', '05205', '05220',
    '05225', '05269', '05272', '05276', '05277', '05290', '05296',
    '05300', '05305', '05320', '05329', '05343', '05345', '05350',
    '05355', '05365', '05375', '05381', '05395', '05400', '05406',
    '05408', '05435', '05440', '05450', '05455', '05469', '05499',
    '05505', '05510', '05529', '05537', '05545', '05575', '05735',
    '05880', '05889', '05935', '05945', '05970', '05986', '05994',
    '06019', '06031', '06032', '06041', '06049', '06051', '06052',
    '06056', '06058', '06065', '06068', '06072', '06073', '06074',
    '06079', '06081', '06082', '06088', '06093', '06096', '06102',
    '06116', '06119', '06123', '06124', '06126', '06132', '06135',
    '06136', '06138', '06141', '06147', '06149', '06151', '06154',
    '06156', '06159', '06168', '06169', '06174', '06181', '06183',
    '06184', '06186', '06187', '06188', '06193', '06197', '20000',
    '20030', '20055', '20085', '20228', '20279', '20315', '20375',
    '20400', '20552', '20561', '20600', '20670', '21020', '21080',
    '21100', '21120', '21160', '21208', '21368', '21430', '22020',
    '22080', '22162', '22189', '22232', '22410', '23100', '23133',
    '23160', '23327', '23360', '24043', '24102', '24142', '24171',
    '24380', '24430', '24490', '25045', '25161', '25270', '25339',
    '26210', '26340', '26358', '26450', '27008', '27082', '28032',
    '28110', '28240', '28280', '28385', '28552', '28590', '29020',
    '29194', '29243', '29330', '29440', '30075', '30187', '30215',
    '30414', '31040', '31185', '31199', '31259', '31350', '31400',
    '31509', '31570', '32110', '32175', '34270', '34320', '34339'
]

all_parameters = [
    # Cloud cover and height
    'cloud_cover', 'cloud_height',
    # Humdity
    'humidity', 'humidity_past1h',
    # Precipitation
    'precip_past10min', 'precip_past1h', 'precip_past24h',
    # Pressure
    'pressure', 'pressure_at_sea',
    # Radiation
    'radia_glob', 'radia_glob_past1h',
    # Temperature
    'temp_dew', 'temp_dry', 'temp_max_past12h', 'temp_max_past1h',
    'temp_mean_past1h', 'temp_min_past12h', 'temp_min_past1h',
    # Visibilty and weather
    'visib_mean_last10min', 'visibility', 'weather',
    # Wind speed and direction
    'wind_dir', 'wind_dir_past1h', 'wind_gust_always_past1h', 'wind_max',
    'wind_max_per10min_past1h', 'wind_min', 'wind_min_past1h',
    'wind_speed', 'wind_speed_past1h',
]

Due to poor design of the API, it is only possible to request one station or all stations, and similarly, it is only possible to request one parameter or all parameters. To be able to select a subset of stations or parameters it is therefore necessary to loop as shown below. This also avoids hitting the rather low maximum amount of data that can be transferred for each request. The implementation below is most suitable for downloading a few stations and a few parameters, and will incur a significant performance penalty if downloading data for all stations.

# Specify the desired start and end time
start_time = pd.Timestamp(2022, 1, 1)
end_time = pd.Timestamp(2022, 1, 15)

# Specify one or more station IDs or all_stations
stationIds = ['04250', '06188']
# Specify one or more parameter IDs or all_parameters
parameterIds = ['radia_glob', 'wind_speed']

# Derive datetime specifier string
datetime_str = start_time.tz_localize('UTC').isoformat() + '/' + end_time.tz_localize('UTC').isoformat()

dfs = []
for station in stationIds:
    for parameter in parameterIds:
        # Specify query parameters
        params = {
            'api-key' : api_key,
            'datetime' : datetime_str,
            'stationId' : station,
            'parameterId' : parameter,
            'limit' : '300000',  # max limit
        }

        # Submit GET request with url and parameters
        r = requests.get(DMI_URL, params=params)
        # Extract JSON object
        json = r.json() # Extract JSON object
        # Convert JSON object to a MultiIndex DataFrame and add to list
        dfi = pd.json_normalize(json['features'])
        if dfi.empty is False:
            dfi['time'] = pd.to_datetime(dfi['properties.observed'])
            # Drop other columns
            dfi = dfi[['time', 'properties.value', 'properties.stationId', 'properties.parameterId']]
            # Rename columns, e.g., 'properties.stationId' becomes 'stationId'
            dfi.columns = [c.replace('properties.', '') for c in dfi.columns]
            # Drop identical rows (considers both value and time stamp)
            dfi = dfi[~dfi.duplicated()]
            dfi = dfi.set_index(['parameterId', 'stationId', 'time'])
            dfi = dfi['value'].unstack(['stationId','parameterId'])
            dfs.append(dfi)

df = pd.concat(dfs, axis='columns').sort_index()
df.head()
stationId 04250 06188
parameterId radia_glob wind_speed radia_glob wind_speed
time
2022-01-01 00:00:00+00:00 0.0 3.6 0.0 4.9
2022-01-01 00:10:00+00:00 0.0 4.0 0.0 5.5
2022-01-01 00:20:00+00:00 0.0 3.8 0.0 4.8
2022-01-01 00:30:00+00:00 0.0 3.8 0.0 5.3
2022-01-01 00:40:00+00:00 0.0 3.8 0.0 5.9

If the request was succesfull, the dataframe df now contains the requested data. The dataframe is a MultiIndex dataframe and has two column levels (station and parameter). The index is the observation time.

MultiIndex dataframes are extremely convenient and versatile, though they do take some time getting used to. As an example, the below command demonstrates how to get the wind speed from the station 04250 for four days in December:

df.loc['2022-01-05':, ('04250', 'wind_speed')]
time
2022-01-05 00:00:00+00:00    9.2
2022-01-05 00:10:00+00:00    7.5
2022-01-05 00:20:00+00:00    6.1
2022-01-05 00:30:00+00:00    4.4
2022-01-05 00:40:00+00:00    4.4
                            ... 
2022-01-14 23:20:00+00:00    5.5
2022-01-14 23:30:00+00:00    4.7
2022-01-14 23:40:00+00:00    4.8
2022-01-14 23:50:00+00:00    5.0
2022-01-15 00:00:00+00:00    4.4
Freq: 10min, Name: (04250, wind_speed), Length: 1441, dtype: float64

The last step is to visualize the data. As an example, we’ll visualize the wind speed and global horizontal irradiance (GHI) for the station 04250.

station = '04250'
params = ['wind_speed', 'radia_glob']  # parameters to plot

# Generate plot of data
ax = df[station][params].plot(figsize=(8,5), legend=False, fontsize=12, rot=0, subplots=True)
ax[0].set_ylabel('Air temperature [$^\circ$C]', size=12)
ax[1].set_ylabel('Global horizontal\nirradiance [W/m$^2$]', size=12)
ax[1].set_xlabel('', size=12)
Text(0.5, 0, '')
../../_images/9046c1b8257c515745a476a970db7b834e27deca4dcf02784baa11aaab9c8ce2.png