OFAC · GuideLast reviewed 2026-09-04
    Sanctions News Desk

    An OFAC SDN check in 85 lines of Python, and the eight gaps it leaves open

    OFAC publishes the SDN List as free machine-readable files with a dated delta for every change, so the basic automated check is an afternoon's work: we wrote one in 85 lines of Python and ran it on 4 September 2026 against the list published on 3 September. Everything hard sits outside the name match — aliases, transliteration, vessels, the 50 Percent Rule and the evidence you keep. Which of those you can live without is the real choice between building the check, buying an API and buying a suite.

    Published By ScreenVeritAI Team
    AT A GLANCE
    SDN.CSV primary names
    19,326
    Rows in SDN.CSV published 3 September 2026: 9,926 entities, 7,518 individuals, 1,540 vessels, 342 aircraft.
    ALT.CSV alternate names
    20,145
    19,597 a.k.a., 530 f.k.a., 17 n.k.a. in the same publication.
    Delta files
    1 per publication
    XML delta archive since 22 September 2022; ten publications between 30 July and 3 September 2026.
    Conservative threshold
    0.85 token_sort_ratio
    'Mohammed Ali' vs MUHAMMAD ALI scores 0.83 and is missed; 'Banco Exterior Cuba' vs BANCO EXTERIOR DE CUBA scores 0.93 and is caught (rapidfuzz 3.14).
    EU Financial Sanctions Files
    EU Login required
    webgate.ec.europa.eu/fsd/fsf redirects to EU Login; the Commission's documentation describes DELTA, GLOBAL and ANNUAL files.

    Short answer

    ANSWER

    OFAC gives you the files and a delta for every change, but no endpoint that takes a name and returns a decision, so the matching is yours to write or to buy. Writing it is Path A: download SDN.CSV and ALT.CSV from OFAC's Sanctions List Service, normalise the names, run a conservative fuzzy match, and log the file's Last-Modified header and hash as evidence; the 85-line script below does exactly that, and ran on 4 September 2026 against the list published on 3 September. Buying it is Path B, a screening API (one HTTP call per check, candidates with scores, matched aliases and a stored evidence record, no list maintenance), or Path C, an enterprise suite that adds case management, payment screening and model governance for a review team. Path A gives you a list hit and nothing else: no weak aliases, no transliteration, no date-of-birth tie-breaks, no vessels, no 50 Percent Rule and no audit trail, and the table further down says what closing each of those takes.

    Path A, built on OFAC's own files

    BUILD

    OFAC publishes the SDN List through the Sanctions List Service. The data centre at sanctionslist.ofac.treas.gov listed these files on 4 September 2026, with the list last updated on 3 September:

    FileWhat it isSize
    SDN.CSV, ADD.CSV, ALT.CSV, SDN_COMMENTS.CSVPrimary names, addresses, alternate names and remarks spill-over. No header row; null is written as -0-; linked by ent_num5.41 MB, 1.61 MB, 1.01 MB, 44 KB
    SDN.XML (SDN_XML.ZIP)The same fields as the CSV set in one XML document27.63 MB (2.44 MB zipped)
    SDN_ADVANCED.XML (.ZIP)Advanced data standard: typed identifiers, low-quality alias flags, scripts, relationships120.71 MB (5.42 MB zipped)
    SDN_ENHANCED.XML (.ZIP)Enhanced data standard, a third XML schema103.96 MB (6.42 MB zipped)
    SDNLIST.PDF, SDNNEW26.PDFHuman-readable full list and the changes file
    YYYY-MM-DD_delta.xmlOne delta file per publication since 22 September 2022: action="add" or action="remove" on entity records, add, remove or update on individual values (schema DeltaFile.xsd 1.0)11 KB (18 Aug) to 575 KB (24 Aug)
    XML.xsd, ADVANCED_XML.xsd, ENHANCED_XML.xsd, DAT_SPEC.TXTSchemas and the CSV / fixed-width specification

    Every export sits under one base URL, https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/, followed by the file name. Delta files are served from /api/download/delta?filename=DeltaArchive%2F2026-08-18_delta.xml.

    The CSV layout comes from DAT_SPEC.TXT. SDN.CSV has twelve columns: ent_num, SDN_Name, SDN_Type, Program, Title, five vessel fields, Vess_owner and Remarks. SDN_Type is individual, vessel, aircraft or -0- for an entity. ALT.CSV has five: ent_num, alt_num, alt_type (aka, fka, nka), alt_name, alt_remarks. The 3 September publication had 19,326 primary rows and 20,145 alternate names.

    The script

    Dependencies: Python 3.10 or later and rapidfuzz. Everything else is the standard library.

    #!/usr/bin/env python3
    """Minimal OFAC SDN check on OFAC's own CSV files.
    
    Dependencies: Python 3.10+ and rapidfuzz (pip install rapidfuzz).
    Usage:
      python ofac_sdn_check.py "Banco Exterior de Cuba" "Wellbred Trading DMCC"
      python ofac_sdn_check.py --sdn ofac-sdn-sample.csv "Wellbred Trading DMCC"
    """
    import argparse, csv, hashlib, io, json, unicodedata, urllib.request
    from datetime import datetime, timezone
    from rapidfuzz import fuzz, process
    
    SLS = ("https://sanctionslistservice.ofac.treas.gov"
           "/api/PublicationPreview/exports/")
    NULL = "-0-"
    
    
    def load(name, local_path=None):
        """Return (text, evidence) for one OFAC file, local or downloaded."""
        if local_path:
            raw, stamp = open(local_path, "rb").read(), "local file"
        else:
            with urllib.request.urlopen(SLS + name, timeout=120) as resp:
                raw, stamp = resp.read(), resp.headers.get("Last-Modified")
        evidence = {"file": name, "last_modified": stamp,
                    "sha256": hashlib.sha256(raw).hexdigest()}
        return raw.decode("utf-8", "replace"), evidence
    
    
    def normalise(name):
        text = unicodedata.normalize("NFKD", name)
        text = "".join(c for c in text if not unicodedata.combining(c))
        text = "".join(c if c.isalnum() else " " for c in text.upper())
        return " ".join(text.split())
    
    
    def build_index(sdn_text, alt_text):
        records, index = {}, {}
        for row in csv.reader(io.StringIO(sdn_text)):
            if len(row) < 12:
                continue
            ent, name, kind, program = row[0], row[1], row[2].strip(), row[3]
            records[ent] = {"sdn_name": name, "program": program,
                            "type": "entity" if kind == NULL else kind}
            index.setdefault(normalise(name), []).append((ent, "primary"))
        for row in csv.reader(io.StringIO(alt_text)):
            if len(row) >= 4 and row[0] in records:
                index.setdefault(normalise(row[3]), []).append((row[0], row[2]))
        return records, index
    
    
    def screen(query, records, index, threshold):
        hits = process.extract(normalise(query), list(index), limit=10,
                               scorer=fuzz.token_sort_ratio,
                               score_cutoff=threshold * 100)
        out = []
        for matched, score, _ in hits:
            for ent, via in index[matched]:
                out.append({"score": round(score / 100, 2), "via": via,
                            "matched": matched, "ent_num": ent, **records[ent]})
        return sorted(out, key=lambda h: -h["score"])
    
    
    if __name__ == "__main__":
        ap = argparse.ArgumentParser()
        ap.add_argument("names", nargs="+")
        ap.add_argument("--sdn", help="local SDN.CSV instead of a download")
        ap.add_argument("--alt", help="local ALT.CSV instead of a download")
        ap.add_argument("--threshold", type=float, default=0.85)
        args = ap.parse_args()
    
        sdn_text, sdn_ev = load("SDN.CSV", args.sdn)
        if args.sdn and not args.alt:      # sample run: no alias file
            alt_text, alt_ev = "", {"file": "ALT.CSV", "note": "not loaded"}
        else:
            alt_text, alt_ev = load("ALT.CSV", args.alt)
        records, index = build_index(sdn_text, alt_text)
    
        run = {"screened_at": datetime.now(timezone.utc).isoformat("T", "seconds"),
               "threshold": args.threshold, "list_evidence": [sdn_ev, alt_ev],
               "primary_names": len(records), "names_indexed": len(index)}
        print(json.dumps(run))
        for name in args.names:
            hits = screen(name, records, index, args.threshold)
            print(json.dumps({"query": name, "candidates": hits}))
    

    What a run looks like

    We ran it on 4 September 2026 with a live download. The first line is the evidence record; the second is the result for one name added on 3 September. Download and match together took about seven seconds.

    {"screened_at": "2026-09-04T12:53:11+00:00", "threshold": 0.85,
     "list_evidence": [
      {"file": "SDN.CSV", "last_modified": "Thu, 03 Sep 2026 15:37:44 GMT",
       "sha256":
       "447d09384cf840252569a30b5db51d8cc4aefb396c085168246ee04e7ba2693c"},
      {"file": "ALT.CSV", "last_modified": "Thu, 03 Sep 2026 15:37:44 GMT",
       "sha256":
       "4511702ccd96b06ae8d5b55b1a9a12e938fc37a38da371e1285d1321b7a4aba6"}],
     "primary_names": 19326, "names_indexed": 38985}
    {"query": "Banco Exterior de Cuba", "candidates": [
      {"score": 1.0, "via": "primary", "matched": "BANCO EXTERIOR DE CUBA",
       "ent_num": "58490", "sdn_name": "BANCO EXTERIOR DE CUBA",
       "program": "CUBA-EO14404", "type": "entity"}]}
    

    To try it without the 7 MB download, use the 46-row sample (13 KB): rows copied verbatim from SDN.CSV for the entries OFAC added between 18 August and 3 September 2026, plus three aircraft and three vessels. It deliberately has no ALT.CSV. Run python ofac_sdn_check.py --sdn ofac-sdn-sample.csv "Wellbred Trading DMCC" and the best candidate is WELLBRED TRADING FZCO at 0.86, a near miss on the primary name. Add --alt ALT.CSV from the real publication and the same query returns 1.00, because WELLBRED TRADING DMCC is that entity's registered a.k.a. That gap is the subject of the next section.

    The choices the script makes for you

    Normalisation strips accents, upper-cases and collapses punctuation, so S.A. and SA agree and Al-Saghir becomes AL SAGHIR. The scorer is token_sort_ratio, which ignores word order (Seye, Abdoulaye and Abdoulaye Seye score 1.00) but not spelling. The cut-off of 0.85 is conservative in one direction only: it keeps false positives down and lets transliterations through.

    QueryAgainstScoreAt 0.85
    Banco Exterior CubaBANCO EXTERIOR DE CUBA0.93Candidate
    Bluwaves Properties LtdBLUWAVES PROPERTIES LIMITED0.92Candidate
    Zolghadr Mohammad BagherZOLQADR, Mohammad Baqer (primary)0.87Candidate
    Mohammed AliMUHAMMAD ALI0.83Missed
    Al-Saghir RuwaaALSAGHEER, Rawa (primary)0.48Missed; 1.00 via its a.k.a. AL-SAGHIR, Ruwaa in ALT.CSV

    What DIY misses

    GapWhy the script misses itWhat closing it takes
    Aliases and weak a.k.a.sALT.CSV carries only the spellings OFAC wrote and no quality marker. The 3 September SDN_ADVANCED.XML flags 4,398 aliases LowQuality="true"; the CSV cannot tell them apartParse the advanced XML; write down whether a weak alias alone can raise an alert
    TransliterationMohammed and Muhammad score 0.83; original-script names sit in the advanced XML as separate translationsScript-aware matching and transliteration tables, then a test set to prove they work
    Date-of-birth tie-breaksIn the CSV, DOB is free text inside Remarks (DOB 10 Dec 1948; ...); the script never reads itParse typed features from the advanced XML; handle multiple and year-only dates; never auto-dismiss on one field
    Vessels and aircraft1,540 vessels and 342 aircraft. The IMO number and the aircraft serial number sit in free text in Remarks (Vessel Registration Identification IMO 7406784); only call sign, type, tonnage and flag have columns, and names are reused and changedMatch on the identifier, not the name; the identifiers are typed in the advanced XML
    The 50 Percent RuleNo list file names the entities blocked by ownershipOwnership data from registries or a KYB check, aggregated across every blocked owner
    List-version evidenceThe script logs Last-Modified and a SHA-256, but nothing keeps the fileStore every downloaded file and every result; process every delta in order
    Audit trailOutput is JSON on stdout; nobody recorded who dismissed the 0.86 and whyA case store with dispositions, reviewer identity, timestamps and retention
    Other listsThe Consolidated non-SDN list (CONS_* files), EU, UK and UN lists are separate downloads with separate semanticsOne loader per list, with the list name on every hit

    None of this is an argument against Path A. It is the specification of what Path A is: a list hit on a primary name or a written alias, with the file version recorded. If that is the decision you need to defend, it is enough.

    Path B, a screening API

    INTEGRATE

    A screening API moves the list maintenance, the matching logic and the evidence store behind one endpoint. You send a name and whatever else you know, such as a date of birth, a country or an entity type, and you get back candidates with scores, the field or alias that produced each score, the list and programme, and a record you can retrieve later. The vendor pulls every publication and delta, parses the advanced XML, and carries the vessel identifiers and weak aliases you would otherwise parse yourself.

    Re-screening is where this path earns its keep. A delta from OFAC is a list of changed records; the work is running your whole book against the new names, re-running every customer whose earlier candidates touched a changed record, and keeping the old result and the new one side by side. A provider that does this on every publication turns a weekly chore into a notification. Batch is the same argument at scale: a CSV of a thousand suppliers should come back as a thousand evidence records, not a thousand JSON lines.

    The questions to ask are the ones the DIY table raises: whether the response says which alias matched or only a score, whether it states the list version, whether you can fetch the evidence a year later without it being recalculated against the current list, whether vessels and the Consolidated list are included, and what happens to the customers you already screened when OFAC publishes a delta. ScreenVeritAI's REST API runs this pattern (POST /api/v1/search returns a search id, GET /api/v1/search/{search_id} returns the result) and stores each check as a point-in-time PDF; the OFAC screening API page has the request and response shapes.

    Path C, an enterprise suite

    SCALE

    An enterprise platform adds what neither a script nor a single endpoint provides: alert queues with four-eyes review and service levels, real-time screening of payment messages, model tuning with documented thresholds per list and per customer segment, and the model-risk paperwork a banking supervisor expects. It is bought by a review operation, not by a developer. The costs are the implementation project, the tuning cycle, and a per-seat or per-volume licence that rarely appears on a public price list. The supervisor's questions follow the platform in: how thresholds were set per list, how the test set was built, how often the model is re-validated, and who signed off the last tuning change. A suite gives you the tooling for those answers. It does not write them.

    Where each path stops making sense

    DECISION

    Volume matters less than the question you have to answer afterwards. Our rule of thumb, stated as opinion:

    SituationPathWhere it stops
    Occasional checks, an internal question, no regulator asking for recordsAThe first time someone asks "what did we check this name against, and who decided it was not a match"
    Recurring checks on customers or suppliers, an obligation under sanctions or AML law, evidence needed per checkBWhen screening has to sit inside a payment flow, or when a team of reviewers needs queues and service levels
    Thousands of alerts a month, several reviewers, in-flight payment screening, supervisory model reviewsCOnly when that operation exists; before then the licence and the project cost buy capacity nobody uses

    Two boundaries hold whatever the volume. If the check has to be defended later, Path A needs a file store and a decision log bolted on, and at that point you have built a small version of Path B. And if you need the 50 Percent Rule, no path solves it from list files alone; the ownership data has to come from somewhere.

    The EU's Financial Sanctions Files, behind a login

    EU

    The EU's consolidated list of persons, groups and entities subject to financial sanctions is distributed through the Commission's Financial Sanctions Files portal at webgate.ec.europa.eu/fsd/fsf. On 4 September 2026 the address redirected straight to EU Login with the message that the service requires authentication. The Commission's FSF user manual confirms that an FSF account is created through EU Login, and describes an "FSD Files" area that lists each file with its checksum, a notification feed for new publications, and tokenised URLs for crawlers in which the token is the user's own identifier.

    The documentation describes three file families. DELTA contains the updates introduced by the most recently published regulation, GLOBAL is a snapshot of all active records, and ANNUAL is the entire database with the history of modifications and deletions. The files are posted, in principle, on the day the Official Journal is published, in XML and CSV, and the tokenised file names carry the schema version (_1_1).

    We tested what we could without an account. A tokenised URL for the full list returned a complete XML and CSV file (about 25 MB each) with no login, which looks like good news until you read the file's own date: the XML's generationDate was 5 August 2026 and its newest regulation was published on 23 July, so the 21 and 27 August amendments to Regulation (EC) No 881/2002 were not in it. An anonymous URL is not evidence of a current list. Use the token from your own account and record generationDate with every run. We could not test the DELTA and ANNUAL files without an account, so treat their endpoints as unverified here.

    The XML itself is richer than OFAC's CSV set. Each nameAlias carries a strong attribute, each birth date and citizenship is typed, and each record cites the regulation and Official Journal page that listed it, so a hit can be traced to its legal basis. And because the file is built from Council regulations rather than from one list, the programme code on the record matters as much as the name.

    What to do next

    ACTIONS
    1. Decide which question the check must answer later: "was the name on the list" (Path A) or "what did we decide, against which version, and why" (B or C).
    2. If you build, keep every file you download, name it by Last-Modified, and process every delta in order. The archive goes back to 22 September 2022.
    3. Build a test set of true and false matches before choosing a threshold, and keep the set with the code.
    4. Write the alias policy down: weak a.k.a.s, transliterations, and the identifier used for vessels and aircraft.
    5. Add ownership data for the 50 Percent Rule, or record in the procedure that the check does not cover it.
    6. For EU obligations, request an FSF account now; the tokenised URLs and the notification feed are what an automated pull will use.

    Frequently asked questions

    Q&A

    Where do I download the OFAC SDN list as a file?

    From the Sanctions List Service data centre at sanctionslist.ofac.treas.gov. The files are SDN.CSV, ADD.CSV, ALT.CSV and SDN_COMMENTS.CSV (comma-delimited, no header row, null written as -0-), SDN.XML, SDN_ADVANCED.XML and SDN_ENHANCED.XML with their XSD schemas, plus fixed-width and PDF versions. The direct export URLs sit under sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/.

    Does OFAC offer an API for screening names?

    Not a screening endpoint. The Sanctions List Service exposes file exports under an /api/ path and OFAC runs the Sanctions List Search web tool, but nothing takes a name and returns a decision. The export URLs are stable enough to script, the HTTP response carries a Last-Modified header you can log, and the delta archive gives you one XML file per publication; the matching logic is yours to write or buy.

    How often does the SDN list change?

    Often enough that a weekly job is late. The delta archive lists ten publications between 30 July and 3 September 2026: 30 July, 5, 6, 7, 18, 20, 24, 26 and 28 August, and 3 September. The 24 August action alone produced a 575 KB delta file. Poll the Last-Modified header daily at least, and process every delta you missed in order.

    What fuzzy-matching threshold should I use for an SDN check?

    There is no universal number, only a documented trade-off. Our snippet uses token_sort_ratio at 0.85, which is conservative: it catches 'Banco Exterior Cuba' against BANCO EXTERIOR DE CUBA (0.93) but misses 'Mohammed Ali' against MUHAMMAD ALI (0.83). Build a test set of names you know are true and false matches, measure both error rates at several thresholds, and keep the test set under version control.

    Does a name check cover OFAC's 50 Percent Rule?

    No. Under OFAC's guidance of 13 August 2014 an entity owned 50 percent or more, directly or indirectly and in the aggregate, by blocked persons is blocked without appearing on the list. No SDN file carries that information. Closing the gap needs ownership data from registries or a KYB check, and a rule that aggregates the holdings of every blocked owner.

    Do I also need OFAC's Consolidated (non-SDN) list?

    Usually yes. The Consolidated Sanctions List files (CONS_PRIM.CSV, CONS_ALT.CSV, CONSOLIDATED.XML, CONS_ADVANCED.XML) carry the non-SDN programmes: Sectoral Sanctions Identifications, Foreign Sanctions Evaders, Non-SDN Menu-Based Sanctions, CAPTA, the Chinese Military-Industrial Complex list and the Palestinian Legislative Council list. Their restrictions differ from blocking, so the record needs to say which list produced the hit.

    How is the EU list distributed compared with OFAC's?

    Through the Financial Sanctions Files portal at webgate.ec.europa.eu/fsd/fsf, which requires an EU Login account; inside, you generate tokenised download URLs and subscribe to notifications. The Commission's documentation describes three file families: DELTA for changes since the last regulation, GLOBAL for a snapshot of active records and ANNUAL for the full history including deletions, in XML and CSV. On 4 September 2026 a public tokenised URL for the full list worked without signing in, but the file it returned had been generated on 5 August and lacked the August regulations. Use your own account's token and log the generationDate; we could not test the DELTA and ANNUAL files without an account.

    Sources

    SOURCES

    Informational analysis of published regulatory sources. Not legal advice. Verify the primary sources before acting.