Sanctions Screening in Python: OFAC, UN, EU and UK Lists

Your onboarding form has a name field, and somewhere between that field and the first payout you are legally required to check whether the person behind it is on a sanctions list. Comparing strings with == against a downloaded spreadsheet does not count: listed names arrive transliterated, abbreviated, reordered and under aliases, and the lists change several times a week.
This tutorial builds sanctions screening in Python from the ground up. By the end you will have a small SanctionsClient module that screens one name or a whole CSV against the OFAC, UN, EU and UK lists, a decision function that tells a real hit from a false positive, an audit log that records which list versions each check ran against, and a nightly job that re-screens your customer base when any list changes.
The matching is done by the Sanctions Screening API, so the Python side stays short. Everything below runs as-is with pip install requests and an API key.
The Ten-Line Version
One GET request screens a name against every list at once. The spelling below is deliberately off (the listed form is Yanukovych); fuzzy matching is the whole point:
import requests
API = "https://api-sanctions.dataandapis.com/v1/screen"
HEADERS = {"X-Api-Key": "YOUR_API_KEY"}
r = requests.get(API, params={"name": "Viktor Yanukovich", "min_score": 0.85},
headers=HEADERS, timeout=15)
r.raise_for_status()
result = r.json()
print(result["hit"], result["count"])
for m in result["matches"]:
print(m["score"], m["match_type"], m["matched_name"], m["entity"]["source"])
True 3
1 exact Viktor Fedorovich Yanukovich UK-SL
0.9765 strong Viktor Yanukovych WIKIDATA
0.95 strong Viktor Fedorovych YANUKOVYCH OFAC-SDN
Three lists, three different spellings, one answer. The UK Sanctions List happens to carry the exact alias you typed, OFAC lists the Ukrainian transliteration, and the third row is a politically exposed person (PEP) record from Wikidata rather than a sanction. The rest of this post is about turning that JSON into a defensible compliance decision.
What You Are Screening Against
Sanctions lists are published by governments as free downloads, each in its own XML or CSV format with its own date conventions. The API consolidates the ones that matter for most fintech and marketplace compliance programmes:
- OFAC SDN (
ofac-sdn): the US Treasury's Specially Designated Nationals list. Individuals, companies, vessels and aircraft whose assets are blocked. Around 19,500 entries. - OFAC Consolidated (
ofac-cons): the non-SDN lists (sectoral sanctions, foreign sanctions evaders, and others). Smaller, but a hit here still needs a human look. - UN Security Council Consolidated List (
un): the list every UN member state is obliged to enforce. - EU Consolidated Financial Sanctions List (
eu-fsf): everyone subject to EU asset freezes, published by the European Commission. - UK Sanctions List (
uk-sl): published by the Foreign, Commonwealth & Development Office. Since January 2026 this is the single UK list; the older OFSI consolidated list was retired, and the API still acceptssource=UK-OFSIas an alias so existing integrations keep working. - Politically exposed persons (
wikidata-pep): heads of state and government and ministers, from Wikidata. A PEP is not sanctioned; the record flags that enhanced due diligence applies.
GET /v1/lists returns the publisher, licence, file URL, entry count and publication date of each list. You will use that endpoint in step 5 to detect changes.
Why Exact Matching Fails
A listed person has one primary name and, typically, several aliases, often in more than one script. The same Arabic or Cyrillic name is romanised differently by the US, the UK and the EU: Yanukovych, Yanukovich, Ianukovytch. Customers type their names with diacritics, without middle names, surname first, or with a transliteration none of the publishers chose. Any of these defeats a string comparison, and each miss is a compliance failure on your side, not the customer's.
The API scores every alias of every candidate entity and reports the best one. Under the hood, both the query and the listed names are normalised (Unicode decomposition, diacritics stripped, punctuation removed), tokens are folded to a transliteration-insensitive consonant skeleton so Yanukovych and Ianukovich land on the same key, and the final score combines Jaro-Winkler string similarity with a token-set ratio. The token comparison is deliberately asymmetric: extra words on the listed name are not evidence against a match, so a two-word query can still fully match a five-word formal entry.
Three fields in each match tell you how good it is:
- score: 0 to 1. Only matches at or above
min_scoreare returned (default 0.75, floor 0.5). - match_type:
exact(score 1),strong(0.9 and above) orfuzzy. - matched_name_kind and matched_name_quality: whether you hit the primary name or an alias, and whether the publisher rates that alias as strong or weak. A weak alias at 0.86 deserves less alarm than a primary name at 0.86.
Step 1: A Reusable Client
Put the HTTP details in one place: a session that carries the key, a timeout, and a retry with backoff for rate limits and transient upstream errors. Save this as sanctions.py.
import time
import requests
BASE = "https://api-sanctions.dataandapis.com/v1"
class SanctionsClient:
def __init__(self, api_key, min_score=0.85):
self.s = requests.Session()
self.s.headers["X-Api-Key"] = api_key
self.min_score = min_score
def _call(self, method, path, **kw):
for attempt in range(4):
r = self.s.request(method, BASE + path, timeout=20, **kw)
if r.status_code in (429, 502, 503, 504) and attempt < 3:
time.sleep(2 ** attempt)
continue
r.raise_for_status()
return r.json()
def screen(self, name, **filters):
params = {"name": name, "min_score": self.min_score, "limit": 10, **filters}
return self._call("GET", "/screen", params=params)
def screen_many(self, names, **filters):
body = {"names": list(names), "min_score": self.min_score, **filters}
return self._call("POST", "/screen", json=body)
def lists(self):
return self._call("GET", "/lists")["lists"]
The filters keyword arguments map straight onto the API's query parameters: list (sanction or pep), source (one list slug), type (individual, entity, vessel, aircraft) and country. They work identically on the single and batch endpoints.
Step 2: Screen One Name and Decide
hit alone is not a decision. A strong match on a sanctions list and a fuzzy match on a PEP record both set it to true, and they call for very different responses. Encode the policy once:
from sanctions import SanctionsClient
client = SanctionsClient("YOUR_API_KEY", min_score=0.85)
def decide(result):
"""clear | hold | review | edd (enhanced due diligence)"""
if not result["hit"]:
return "clear"
sanctions = [m for m in result["matches"] if m["entity"]["list_type"] == "sanction"]
if sanctions:
top = sanctions[0] # matches are sorted by score
return "hold" if top["match_type"] in ("exact", "strong") else "review"
return "edd" # PEP only: not prohibited, but look closer
result = client.screen("Viktor Yanukovich")
print(decide(result)) # hold
hold pauses the transaction until a compliance officer confirms or dismisses the match; review queues it without blocking; edd triggers your PEP procedure. Whether a strong match blocks automatically or merely holds is a policy question for your compliance team, not for the code, which is exactly why it lives in one small function.
Each match carries the entity's programs, countries, birth_dates and listed_on fields. For common names, those are what the reviewer will use to clear a false positive, so surface them in the review queue rather than only the score.
Step 3: Screen a CSV in Batches
Onboarding screens one name at a time, but the first run against an existing customer base, and every re-screen after a list update, is a batch job. POST /v1/screen accepts up to 25 names per call and returns one result per name, in the same order you sent them.
import csv
def chunks(seq, n=25):
for i in range(0, len(seq), n):
yield seq[i:i + n]
with open("customers.csv", newline="") as f:
rows = list(csv.DictReader(f)) # columns: id, full_name
flagged = []
for batch in chunks(rows):
res = client.screen_many((r["full_name"] for r in batch), list="sanction")
for row, item in zip(batch, res["results"]):
if "error" in item: # empty or over-long name
continue
if item["hit"]:
best = item["matches"][0]
flagged.append((row["id"], row["full_name"], best["score"],
best["matched_name"], best["entity"]["source"]))
print(f"{len(flagged)} of {len(rows)} customers need review")
Passing list="sanction" keeps PEP records out of the automated sweep so the review queue stays actionable; run the PEP pass separately with list="pep" for the customer segments where it applies. A batch request is billed as one API call, the same as a single name, so batching saves tokens as well as round trips.
Step 4: Keep an Audit Record
When a regulator asks why a customer was cleared in March, "we screened them" is not an answer. You need the query as typed, the normalised form, the threshold, the matches, and the version of every list at that moment. Every screening response includes screened_against for exactly this reason:
"screened_against": [
{"slug": "eu-fsf", "publish_date": "2026-10-23", "retrieved_at": "2026-10-30", "entity_count": 6269},
{"slug": "ofac-sdn", "publish_date": "10/19/2026", "retrieved_at": "2026-10-30", "entity_count": 19484},
{"slug": "uk-sl", "publish_date": "2026-10-29", "retrieved_at": "2026-10-30", "entity_count": 6420},
...
]
Write one line per check to an append-only log. JSON Lines is enough to start; the same record maps cleanly onto a database table later.
import json
from datetime import datetime, timezone
def audit_record(customer_id, result, decision):
return {
"customer_id": customer_id,
"screened_at": datetime.now(timezone.utc).isoformat(),
"query": result["query"],
"normalized": result["normalized"],
"min_score": result["min_score"],
"decision": decision,
"matches": [(m["entity"]["source"], m["entity"]["source_ref"], m["score"])
for m in result["matches"]],
"lists": {s["slug"]: s["publish_date"] for s in result["screened_against"]},
}
result = client.screen("Viktor Yanukovich")
with open("screening-log.jsonl", "a") as log:
log.write(json.dumps(audit_record("cust_123", result, decide(result))) + "\n")
source_ref is the publisher's own identifier for the entry (an OFAC UID, a UN reference, a UK group ID), which lets an auditor look the designation up on the official site without trusting your database.
Step 5: Re-screen When a List Changes
Screening at onboarding is table stakes. The customer who was clean in March may be designated in October, and the obligation to notice is yours. Poll /v1/lists once a day, compare publication dates with the ones you saw last time, and rerun the batch from step 3 when anything moved.
import json, os
STATE = "list-versions.json"
def list_versions(client):
return {l["slug"]: l["publish_date"] for l in client.lists()}
current = list_versions(client)
previous = json.load(open(STATE)) if os.path.exists(STATE) else {}
changed = [slug for slug, date in current.items() if previous.get(slug) != date]
if changed:
print("updated lists:", changed, "- re-screening all active customers")
# run the batch loop from step 3 over every active customer here
with open(STATE, "w") as f:
json.dump(current, f)
Re-screening the whole base after every change is the simple, defensible option. If your base is large, re-screen only customers whose names share a token with the newly added entries: GET /v1/entities?source=uk-sl lists every entry on a list with its listed_on date, which is enough to pick out the recent additions.
Gotchas That Matter
- Common names hit. "Maria Garcia" returns exact matches because people with that name are genuinely listed. The match is correct; the customer is probably not that person. Use the entity's birth dates, countries and programs to clear them, and consider
country=when you know the customer's nationality. - Short queries over-match long PEP names. Because extra words on the listed side are not penalised, "John Smith" at 0.85 picks up fuzzy PEP records like "Admiral Alan William John West, Baron West of Spithead". Keep the automated gate on
list=sanction, and treat afuzzyPEP match as a review item, never an automatic flag. - Pick the threshold for the risk, not the demo. 0.85 is a sensible gate for onboarding; a payments desk screening counterparties on every transaction may run 0.8 and accept more reviews. Lowering below 0.75 mostly adds noise. Record the threshold in the audit log so a later change does not rewrite history.
- Limits. Names up to 200 characters, 25 names per batch, at most 50 matches per name (
limit). A 400 on the batch endpoint means the whole request was rejected; a per-nameerrorinsideresultsmeans that one entry was empty or too long. - Screen companies and vessels too. Sanctions programmes list entities and ships, not only people. Screen the legal name of every business customer and, for logistics products, the vessel name, with
type=entityortype=vesselto narrow the candidates. - Do not cache a clean result. A clear decision is only valid for the list versions in
screened_against. Cache the audit record, not the verdict.
What You Can Build With This
- Onboarding gate: call
screen()in the signup handler, store the audit record, and route hold results to a compliance queue before the account can transact. - Payout and payee checks: screen beneficiary names on every new payout destination, including the bank account holder name when it differs from the customer.
- Marketplace seller and vendor vetting: batch-screen business names and their directors at application time and after each list update.
- Shipping and trade compliance: screen vessels, shipping companies and consignees against the sanctions lists with
type=vesselandtype=entity. - Nightly monitoring: the step 5 job as a cron task, posting the review count to Slack so the compliance team starts the day with the delta.
Conclusion
Sanctions screening in Python comes down to four pieces: a client that calls one endpoint, a decision function that separates sanctions from PEPs and strong matches from fuzzy ones, an audit log that captures list versions, and a job that re-screens when the lists move. The hard part, fuzzy matching across transliterations and aliases of six official lists, is handled by the API. For the broader question of when to screen and what to do after a hit, see our guide to screening customers against sanctions lists.
Get an API key and try the ten-line version with your own test names on the Sanctions Screening API page; the free plan’s 500 calls are enough to wire up and test an onboarding flow.