Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ Available formats
cz.bankaccount
cz.dic
cz.rc
de.blz
de.handelsregisternummer
de.idnr
de.leitweg
Expand Down
5 changes: 5 additions & 0 deletions docs/stdnum.de.blz.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
stdnum.de.blz
=============

.. automodule:: stdnum.de.blz
:members:
3,508 changes: 3,508 additions & 0 deletions stdnum/de/banks.dat

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions stdnum/de/blz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# blz.py - functions for handling German bank codes
# coding: utf-8
#
# Copyright (C) 2026 Claude-Alain Martin
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, see <https://www.gnu.org/licenses/>.

"""BLZ (Bankleitzahl, German bank code).

The Bankleitzahl is an 8 digit number that identifies a bank (or a branch
that takes part in payment transactions) in Germany. It is found in
positions 5 to 12 of a German IBAN. The Deutsche Bundesbank publishes the
register of allocated codes with the institution, its BIC and its place,
and marks codes that are about to be deleted, with the successor code where
one exists.

More information:

* https://en.wikipedia.org/wiki/Bankleitzahl
* https://www.bundesbank.de/de/aufgaben/unbarer-zahlungsverkehr/serviceangebot/bankleitzahlen

>>> validate('10000000')
'10000000'
>>> validate('370 400 44')
'37040044'
>>> import json
>>> print(json.dumps(info('10000000'), indent=2, sort_keys=True))
{
"bank": "Bundesbank",
"bic": "MARKDEF1100",
"city": "Berlin"
}
>>> to_bic('37040044')
'COBADEFFXXX'
>>> validate('12345678') # not allocated
Traceback (most recent call last):
...
InvalidComponent: ...
>>> validate('1234567')
Traceback (most recent call last):
...
InvalidLength: ...
>>> validate('ABCDEFGH')
Traceback (most recent call last):
...
InvalidFormat: ...
"""

from __future__ import annotations

from stdnum.exceptions import *
from stdnum.util import clean, isdigits


def compact(number: str) -> str:
"""Convert the number to the minimal representation. This strips the
number of any valid separators and removes surrounding whitespace."""
return clean(number, ' ').strip()


def info(number: str) -> dict[str, str]:
"""Return a dictionary of data about the supplied number. This typically
returns the name of the bank, its BIC and its place, and for a code that
is about to be deleted the successor code where one exists."""
number = compact(number)
from stdnum import numdb
return numdb.get('de/banks').info(number)[0][1]


def to_bic(number: str) -> str | None:
"""Return the BIC for the bank that this number refers to."""
return info(number).get('bic')


def validate(number: str) -> str:
"""Check if the number is a valid bank code. This checks the format and
whether the code is allocated in the register of the Deutsche
Bundesbank."""
number = compact(number)
if not isdigits(number):
raise InvalidFormat()
if len(number) != 8:
raise InvalidLength()
if not info(number):
raise InvalidComponent()
return number


def is_valid(number: str) -> bool:
"""Check if the number is a valid bank code."""
try:
return bool(validate(number))
except ValidationError:
return False
67 changes: 67 additions & 0 deletions tests/test_de_blz.doctest
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
test_de_blz.doctest - more detailed doctests for the stdnum.de.blz module

Copyright (C) 2026 Claude-Alain Martin

This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.

This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public
License along with this library; if not, see <https://www.gnu.org/licenses/>.


This file contains more detailed doctests for the stdnum.de.blz module. It
tries to test more corner cases and detailed functionality that is not really
useful as module documentation.

>>> from stdnum.de import blz


Codes of the register are valid, whatever the spacing:

>>> blz.validate('37040044')
'37040044'
>>> blz.compact(' 700 500 00 ')
'70050000'
>>> blz.is_valid('70050000')
True
>>> blz.to_bic('70050000')
'BYLADEMMXXX'


A code that is about to be deleted is still in the register (it was really
allocated) and names its successor when the Bundesbank publishes one:

>>> import json
>>> print(json.dumps(blz.info('10060198'), indent=2, sort_keys=True))
{
"bank": "Pax-Bank",
"bic": "GENODED1PA6",
"city": "Berlin",
"deleted": "True",
"successor": "37060193"
}
>>> blz.is_valid(blz.info('10060198')['successor'])
True


Anything that is not an allocated 8 digit code is rejected:

>>> blz.is_valid('12345678')
False
>>> blz.is_valid('1234567')
False
>>> blz.is_valid('123456789')
False
>>> blz.is_valid('1000000O')
False
>>> blz.info('12345678')
{}
>>> blz.to_bic('12345678') is None
True
99 changes: 99 additions & 0 deletions update/de_banks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
# coding: utf-8

# update/de_banks.py - script to download the Bankleitzahl file from the
# Deutsche Bundesbank
#
# Copyright (C) 2026 Claude-Alain Martin
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, see <https://www.gnu.org/licenses/>.

"""This script downloads the list of German bank codes (Bankleitzahlen) with
the institution, BIC and place as published by the Deutsche Bundesbank."""

import csv
import io
import os.path
import re

import requests


# The download page lists the current edition of the file; the file's own
# URL changes with every edition, so it is found on the page.
download_page = (
'https://www.bundesbank.de/de/aufgaben/unbarer-zahlungsverkehr/'
'serviceangebot/bankleitzahlen/download-bankleitzahlen-602592')

# The file that is looked up on the download page (semicolon-separated CSV).
download_file = 'blz-aktuell-csv-data.csv'


# The user agent that will be passed in requests
user_agent = 'Mozilla/5.0 (compatible; python-stdnum updater; +https://arthurdejong.org/python-stdnum/)'


def find_download_url(html):
"""Return the URL of the CSV file from the download page."""
match = re.search(r'href="([^"]*%s)"' % re.escape(download_file), html)
if not match:
raise ValueError('%s not found on %s' % (download_file, download_page))
url = match.group(1)
if url.startswith('/'):
url = 'https://www.bundesbank.de' + url
return url


def get_values(csv_reader):
"""Return values (blz, bic, bank, city, deleted, successor) from the CSV.

The file has one row per institution and bank code (Merkmal 1) and extra
rows for branches that use the code of another institution (Merkmal 2);
only the former describe the code itself."""
# skip first row (header)
try:
next(csv_reader)
except StopIteration:
pass # ignore empty CSV
for row in csv_reader:
if len(row) < 13 or row[1] != '1':
continue
blz, _merkmal, bank, _plz, city, _short, _pan, bic = row[:8]
deleted = row[11] == '1'
successor = row[12] if row[12] not in ('', '00000000') else ''
yield blz, bic, bank.strip(), city.strip(), deleted, successor


if __name__ == '__main__':
response = requests.get(download_page, timeout=30, headers={'User-Agent': user_agent})
response.raise_for_status()
download_url = find_download_url(response.text)
response = requests.get(download_url, timeout=30, headers={'User-Agent': user_agent})
response.raise_for_status()
csv_reader = csv.reader(io.StringIO(response.content.decode('latin-1')), delimiter=';')
print('# generated from %s downloaded from' % os.path.basename(download_url))
print('# %s' % download_page)
for blz, bic, bank, city, deleted, successor in get_values(csv_reader):
info = '%s' % blz
if bic:
info += ' bic="%s"' % bic
if bank:
info += ' bank="%s"' % bank
if city:
info += ' city="%s"' % city
if deleted:
info += ' deleted="True"'
if successor:
info += ' successor="%s"' % successor
print(info)
Loading