# Zinc Database

> Query the ZINC22 virtual compound library (CartBlanche API, billions of make-on-demand + purchasable molecules). Look up substances by ZINC ID, resolve a SMILES to its ZINC ID (exact match), inspect purchasability/catalogs, and assemble compound sets for docking. Property (MW/logP) filtering is done locally with RDKit. For bioactivity use chembl-database-bioactivity; for approved drugs use drugbank-database-access.

- Skill: `fridrichmethod/zinc-database` (Agent Skill)
- Install (CLI): `npx skillmds@latest add fridrichmethod/zinc-database`
- Raw SKILL.md: https://api.skillmd.com/api/skills/fridrichmethod/zinc-database/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: CC-BY-4.0
- Author: FridrichMethod (https://skillmd.com/u/fridrichmethod)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/fridrichmethod/zinc-database

---


# ZINC Chemical Library Database (ZINC22 / CartBlanche)

## Overview

ZINC (ZINC Is Not Commercial) is a free database of commercially available compounds curated for virtual screening. The current generation, **ZINC22**, holds billions of make-on-demand and in-stock molecules and is served through the **CartBlanche** web application at `cartblanche22.docking.org`. CartBlanche exposes a small JSON API to look up substances by ZINC ID, resolve a SMILES to its ZINC identifier, and inspect supplier/catalog purchasability.

> **Important — the old ZINC15 REST API no longer works for automated access.**
> `zinc15.docking.org` (and `zinc.docking.org`) now sits behind a site-wide CAPTCHA: every request to `/substances.json`, `/tranches/...`, `/substances/{id}.json`, etc. is redirected to a `/captcha/` page or returns `403 Forbidden`. Any script using the `mwt__gte` / `availability` / `similarity` query parameters against `zinc15.docking.org` will fail. Use the ZINC22 CartBlanche endpoints documented below instead.

Three things about ZINC22/CartBlanche that change how you use this skill:

1. **There is no server-side property-range query.** ZINC22/CartBlanche has no `mwt__gte` / `logp__lte` / `hbd__lte` search. You filter by molecular property by either (a) selecting **tranches** (a 2D MW x logP grid) for bulk download, or (b) retrieving compounds and filtering locally with RDKit. This skill shows the local-RDKit approach.
2. **SMILES lookup is asynchronous.** You POST a SMILES, receive a `task` id, and poll for the result. Exact-match lookup completes in seconds.
3. **Reliable programmatic search = exact match.** The API's broader analog search (`dist` > 0) is slow and frequently times out on the public server, and the Smallworld similarity search is a website-only flow. Treat exact SMILES → ZINC ID lookup as the dependable primitive; see the "Analog / Similarity Search" note for the (limited) alternatives.

## When to Use

- Looking up a known ZINC ID to get its SMILES, computed properties, and purchasability/suppliers
- Resolving a molecule you have as a SMILES to its ZINC22 identifier(s) to check availability
- Checking whether a hit compound is purchasable and from which catalogs before ordering
- Assembling a SMILES/ZINC-ID set to feed into a docking campaign
- For property-filtered library building, combine SMILES/tranche retrieval here with local RDKit filtering (`rdkit-cheminformatics`)
- For known drug bioactivity data use `chembl-database-bioactivity`; for approved drug structures use `drugbank-database-access`

## Prerequisites

- **Python packages**: `requests`, `pandas` (and `rdkit` for local property filtering)
- **Data requirements**: a ZINC ID, or a SMILES string
- **Environment**: internet connection; no API key needed; SMILES lookups are async (poll for the result)
- **Rate limits**: be courteous — serialize searches, cache results, and do not poll the task endpoint faster than once every few seconds

```bash
pip install requests pandas
# optional, for local property filtering:
pip install rdkit
```

## Quick Start

```python
import requests

BASE = "https://cartblanche22.docking.org"
HEADERS = {"User-Agent": "sciagent-zinc-skill/1.0"}

# Look up a substance by ZINC ID (synchronous, returns JSON immediately)
r = requests.get(f"{BASE}/substance/ZINC000000029632.json", headers=HEADERS, timeout=30)
r.raise_for_status()
c = r.json()

td = c["tranche_details"]
print(f"ZINC ID : {c['zinc_id']}  (db: {c['db']})")
print(f"SMILES  : {c['smiles']}")
print(f"MW      : {td['mwt']:.2f}   logP: {td['logp']:.2f}   heavy atoms: {td['heavy_atoms']}")
print(f"InChIKey: {td['inchikey']}")
print(f"Catalogs: {len(c.get('catalogs', []))} supplier entries")
```

Expected output (abridged):

```
ZINC ID : ZINC000000029632  (db: zinc20)
SMILES  : O=C([C@H]1CCCN1C(=O)Cc1c[nH]c2ccccc12)N1CCc2ccccc2C1
MW      : 387.48   logP: 3.29   heavy atoms: 29
InChIKey: QCEMBLKSDIWPBK-JOCHJYFZSA-N
Catalogs: 4 supplier entries
```

## Core API

The CartBlanche base URL and two reusable helpers used throughout:

```python
import requests, time

BASE = "https://cartblanche22.docking.org"
HEADERS = {"User-Agent": "sciagent-zinc-skill/1.0"}

def get_substance(zinc_id):
    """Fetch full record for one ZINC ID (synchronous)."""
    r = requests.get(f"{BASE}/substance/{zinc_id}.json", headers=HEADERS, timeout=30)
    r.raise_for_status()
    return r.json()

def smiles_lookup(smiles, dist=0, adist=0, database="zinc22", timeout=120, poll=5):
    """Resolve a SMILES to ZINC22 substance dicts via the async task API.

    dist=0, adist=0 -> exact match (fast, reliable; use this).
    dist/adist > 0  -> near-neighbor analog search (slow; may time out — see note).
    Returns a list of substance dicts (possibly empty).
    """
    files = {"smiles": (None, smiles), "dist": (None, str(dist)),
             "adist": (None, str(adist)), "database": (None, database)}
    sub = requests.post(f"{BASE}/smiles.json", files=files, headers=HEADERS, timeout=60)
    sub.raise_for_status()
    task = sub.json()["task"]                       # async: returns {"task": "<uuid>"}

    deadline = time.time() + timeout
    while time.time() < deadline:
        res = requests.get(f"{BASE}/search/result/{task}", headers=HEADERS, timeout=30).json()
        if res.get("status") == "SUCCESS":
            data = res.get("result")
            # result is {"zinc22": [...], "zinc20": [...]} when hits exist,
            # or an empty list when there are none.
            if isinstance(data, dict):
                return data.get("zinc22", []) + data.get("zinc20", [])
            return data or []
        time.sleep(poll)                            # status == "PROGRESS" until done
    raise TimeoutError(f"ZINC22 search task {task} did not finish in {timeout}s")
```

### Query 1: Retrieve a Compound by ZINC ID

Fetch the full record (SMILES, computed properties, formula, ring/heteroatom counts, supplier catalogs) for a known ZINC identifier.

```python
c = get_substance("ZINC000000029632")
td = c["tranche_details"]

print(f"ZINC ID    : {c['zinc_id']}")
print(f"SMILES     : {c['smiles']}")
print(f"Formula    : {c['mol_formula']}")
print(f"MW         : {td['mwt']:.2f}")
print(f"logP       : {td['logp']:.2f}")
print(f"Heavy atoms: {td['heavy_atoms']}")
print(f"Rings      : {c['rings']}   Hetero atoms: {c['hetero_atoms']}")
print(f"InChIKey   : {td['inchikey']}")
print(f"Suppliers  : {len(c.get('catalogs', []))}")
```

Both the modern `ZINCbq...`/`ZINCbj...` style IDs and the legacy 12-digit `ZINC000...` IDs resolve through this endpoint.

### Query 2: Resolve a ZINC ID from a SMILES (Exact Match)

Find the ZINC22 identifier(s) for a molecule you have as a SMILES string. This is the dependable search primitive.

```python
hits = smiles_lookup("CC(=O)Nc1ccc(O)cc1")   # acetaminophen, exact (dist=0)
print(f"Exact matches: {len(hits)}")
for h in hits[:5]:
    td = h["tranche_details"]
    print(f"  {h['zinc_id']:22s}  MW {td['mwt']:.1f}  logP {td['logp']:.2f}  {h['smiles']}")
```

A single molecule can map to several ZINC IDs across ZINC22 sub-databases (e.g. `ZINCbq...` and `ZINCbj...`) — each represents the same structure in a different build.

### Query 3: Purchasability and Supplier Catalogs

Purchasability is embedded in the substance record under `catalogs` — no separate request needed.

```python
c = get_substance("ZINC000000029632")
catalogs = c.get("catalogs", [])
print(f"Supplier entries: {len(catalogs)}")
for cat in catalogs:
    print(f"  {cat.get('catalog_name', 'n/a'):24s} | "
          f"purchasable: {cat.get('purchase')} | "
          f"code: {cat.get('supplier_code', 'n/a')} | "
          f"price: {cat.get('price', 'n/a')} {cat.get('unit', '')}")
```

`purchase: 1` indicates the compound is orderable from that catalog; entries may include price, quantity, and lead time (`shipping`).

### Query 4: Property-Filtered Library (Local RDKit Filtering)

ZINC22 has no server-side MW/logP query. The portable pattern is: gather candidate SMILES (resolved ZINC IDs, a downloaded tranche file, or your own enumeration), then filter locally with RDKit.

```python
import pandas as pd
from rdkit import Chem
from rdkit.Chem import Descriptors, Lipinski

# Candidate SMILES (here: a small hand list; in practice load a tranche .smi or your own set)
smiles_list = [
    "CC(=O)Nc1ccc(O)cc1",            # acetaminophen  (MW 151 - too small, filtered out)
    "CC(C)NCC(O)COc1cccc2ccccc12",   # propranolol    (MW 259, logP 2.6 - lead-like)
    "CN(C)CCOC(c1ccccc1)c1ccccc1",   # diphenhydramine
    "c1ccc(NC(=O)c2ccccc2)cc1",      # benzanilide    (MW 197 - too small, filtered out)
]

def lead_like(smi):
    m = Chem.MolFromSmiles(smi)
    if m is None:
        return None
    mw, logp = Descriptors.MolWt(m), Descriptors.MolLogP(m)
    hbd, hba = Lipinski.NumHDonors(m), Lipinski.NumHAcceptors(m)
    ok = 250 <= mw <= 350 and 1 <= logp <= 3 and hbd <= 3 and hba <= 7
    return {"smiles": Chem.MolToSmiles(m), "mw": round(mw, 1), "logp": round(logp, 2),
            "hbd": hbd, "hba": hba} if ok else None

rows = [r for r in (lead_like(s) for s in smiles_list) if r]
df = pd.DataFrame(rows).drop_duplicates(subset=["smiles"])
print(f"Lead-like compounds after RDKit filter: {len(df)}")
print(df.head())
```

### Analog / Similarity Search (limited — read before relying on it)

The submit endpoint accepts `dist` (topological graph-edit distance, 0–3) and `adist` ("anon" distance, scaffold hops) for a near-neighbor **analog** search:

```python
# Near-neighbor analogs. Keep dist small (1) and the timeout generous; expect FEW results.
try:
    analogs = smiles_lookup("c1ccc(NC(=O)c2ccccc2)cc1", dist=1, adist=0, timeout=240)
    print(f"Analogs: {len(analogs)}")
    for h in analogs[:10]:
        print("  ", h["zinc_id"], h["smiles"])
except TimeoutError as e:
    print("Analog search timed out:", e)
```

Caveats verified against the live public server:
- `dist=0` (exact) is fast and reliable. `dist >= 1` runs across billions of molecules on a shared cluster and **frequently times out** (e.g. `dist=3` did not return within ~270 s); even when it succeeds it often returns only the query and a handful of near-identical structures.
- For broad **whole-molecule similarity** (Tanimoto over graph-edit distance), CartBlanche provides a separate **Smallworld** search on the website (`cartblanche22.docking.org` → Similarity → Smallworld, `/similarity/sw`). It is an interactive flow and is not exposed as a simple GET/POST JSON endpoint here — use the web UI for large similarity jobs.
- Do not build automated pipelines that assume analog search returns a rich set. Base workflows on exact lookup + local RDKit/cheminformatics expansion instead.

## Key Concepts

### ZINC22 IDs

ZINC22 introduced short alphanumeric IDs such as `ZINCbq0000001gLA` (the prefix letters encode the sub-database / tranche). Legacy 12-digit IDs like `ZINC000000029632` (from ZINC15/ZINC20) still resolve through `/substance/{id}.json`. Always pass the ID exactly as given.

### Asynchronous Search Model

SMILES searches are queued as tasks:

1. `POST /smiles.json` (multipart form: `smiles`, `dist`, `adist`, `database`) → `{"task": "<uuid>"}`
2. `GET /search/result/{task}` → `{"status": "PROGRESS"}` while running, then `{"status": "SUCCESS", "result": {...}}`
3. On success, `result` is a dict keyed by sub-database (`zinc22`, sometimes `zinc20`) when there are hits, or an empty list when there are none. The helper above normalizes both shapes.

### Tranches

ZINC organizes molecules into a 2D grid of "tranches" by heavy-atom count / MW (H-codes, e.g. `H11`) and logP (letter bins). The CartBlanche **Tranches** browser (`cartblanche22.docking.org`, "3D"/"2D" tranche pages) lets you select property regions and bulk-download SMILES/SDF for docking. Tranche bulk download is interactive (and large); for programmatic property filtering prefer the RDKit approach in Query 4, or fetch tranche files from `files.docking.org` and filter locally.

### Result Fields

Each search/substance record exposes: `zinc_id`, `smiles`, `mol_formula`, `rings`, `hetero_atoms`, `db`, `catalogs[]`, and `tranche_details` (`mwt`, `logp`, `heavy_atoms`, `inchi`, `inchikey`). SMILES search rows additionally include `sub_id`, `matched_smiles`, and the `tranche` code.

## Common Workflows

### Workflow 1: Check Purchasability of a Hit List

**Goal**: Given SMILES of docking hits, resolve their ZINC22 IDs and report which are purchasable.

```python
import pandas as pd

hit_smiles = [
    "CC(=O)Nc1ccc(O)cc1",            # acetaminophen
    "c1ccc(NC(=O)c2ccccc2)cc1",      # benzanilide
]

rows = []
for smi in hit_smiles:
    matches = smiles_lookup(smi)                   # exact
    if not matches:
        rows.append({"query_smiles": smi, "zinc_id": None, "purchasable": False})
        continue
    for m in matches:
        cats = m.get("catalogs", [])
        rows.append({
            "query_smiles": smi,
            "zinc_id": m["zinc_id"],
            "mw": m["tranche_details"]["mwt"],
            "n_catalogs": len(cats),
            "purchasable": any(c.get("purchase") for c in cats) or bool(cats),
        })

df = pd.DataFrame(rows)
print(df)
df.to_csv("hit_purchasability.csv", index=False)
print("Saved: hit_purchasability.csv")
```

### Workflow 2: Resolve and Property-Filter a Candidate Set for Docking

**Goal**: Take a list of candidate SMILES (your own enumeration or a tranche file), keep the ones present/purchasable in ZINC22, filter to lead-like space, and export SMILES for docking.

```python
import pandas as pd
from rdkit import Chem
from rdkit.Chem import Descriptors

candidates = [
    "CC(=O)Nc1ccc(O)cc1",
    "O=C([C@H]1CCCN1C(=O)Cc1c[nH]c2ccccc12)N1CCc2ccccc2C1",
    "c1ccc(NC(=O)c2ccccc2)cc1",
]

rows = []
for smi in candidates:
    m = Chem.MolFromSmiles(smi)
    if m is None:
        continue
    mw, logp = Descriptors.MolWt(m), Descriptors.MolLogP(m)
    if not (200 <= mw <= 500 and -1 <= logp <= 5):     # lead-like local filter
        continue
    matches = smiles_lookup(smi)                        # confirm it exists in ZINC22
    if not matches:
        continue
    m0 = matches[0]
    rows.append({"zinc_id": m0["zinc_id"], "smiles": Chem.MolToSmiles(m),
                 "mw": round(mw, 1), "logp": round(logp, 2),
                 "purchasable": bool(m0.get("catalogs"))})

df = pd.DataFrame(rows).drop_duplicates(subset=["smiles"])
print(f"ZINC22-confirmed lead-like compounds: {len(df)}")
df[["smiles", "zinc_id"]].to_csv("docking_library.smi", sep=" ", index=False, header=False)
print("Saved: docking_library.smi")
print(df)
```

## Key Parameters

| Parameter | Endpoint | Default | Range / Options | Effect |
|-----------|----------|---------|-----------------|--------|
| (path) | `GET /substance/{zinc_id}.json` | — | ZINC ID | Synchronous substance lookup |
| `smiles` | `POST /smiles.json` | — | valid SMILES | Query molecule |
| `dist` | `POST /smiles.json` | `0` | `0`–`3` | Topological distance; `0` = exact (reliable), `>=1` = analog search (slow, may time out) |
| `adist` | `POST /smiles.json` | `0` | `0`–`3` | Anonymous-graph distance (scaffold hopping); `0` = off |
| `database` | `POST /smiles.json` | `zinc22` | `"zinc22"`, `"zinc20"` | Which ZINC build to search |
| `task` | `GET /search/result/{task}` | — | UUID from submit | Poll handle for async result |

## Best Practices

1. **Use ZINC22 (CartBlanche), not ZINC15.** The `zinc15.docking.org` REST API is CAPTCHA-walled and will not work from scripts. All endpoints here target `cartblanche22.docking.org`.

2. **Lean on exact lookup; don't depend on analog search.** `dist=0` is the reliable primitive. For analogs/similarity, expand locally (RDKit, `rdkit-cheminformatics`) or use the Smallworld web UI — the analog API times out on broad queries.

3. **Filter properties locally with RDKit.** There is no server-side MW/logP/HBD query in ZINC22. Retrieve candidates, then filter with RDKit — this is also where you apply PAINS/Brenk alerts.

4. **Poll politely.** SMILES lookups are async; poll `/search/result/{task}` every few seconds, set a sensible timeout, and handle the `PROGRESS` → `SUCCESS` transition (and `TimeoutError`).

5. **Deduplicate by canonical SMILES.** A structure can appear under multiple ZINC IDs / catalogs. Canonicalize with RDKit (`Chem.MolToSmiles(Chem.MolFromSmiles(smi))`) before docking.

6. **Verify purchasability via `catalogs`.** Treat `catalogs` entries with `purchase: 1` as orderable; record `supplier_code` and `catalog_name` for procurement.

7. **Cache results.** ZINC data updates periodically; cache substance/search JSON with a date-stamped filename and avoid re-querying within a project.

## Common Recipes

### Recipe: Lookup ZINC22 ID and purchasability from a SMILES

```python
matches = smiles_lookup("CC(=O)Nc1ccc(O)cc1")   # acetaminophen
for m in matches:
    cats = m.get("catalogs", [])
    print(f"{m['zinc_id']} | MW {m['tranche_details']['mwt']:.1f} | "
          f"catalogs {len(cats)} | purchasable {bool(cats)}")
```

### Recipe: Batch-resolve a list of ZINC IDs

```python
zinc_ids = ["ZINC000000029632", "ZINCbq0000001gLA"]
for zid in zinc_ids:
    try:
        c = get_substance(zid)
        print(f"{zid}: {c['smiles']}  MW {c['tranche_details']['mwt']:.1f}")
    except requests.HTTPError as e:
        print(f"{zid}: not found ({e.response.status_code})")
```

### Recipe: Property Distribution of a Library

```python
import pandas as pd
from rdkit import Chem
from rdkit.Chem import Descriptors

df = pd.read_csv("docking_library.smi", sep=" ", names=["smiles", "zinc_id"])
df["mw"] = df["smiles"].map(lambda s: Descriptors.MolWt(Chem.MolFromSmiles(s)))
df["logp"] = df["smiles"].map(lambda s: Descriptors.MolLogP(Chem.MolFromSmiles(s)))
print(f"Library size: {len(df)}")
print(df[["mw", "logp"]].describe())
```

## Troubleshooting

| Problem | Cause | Solution |
|---------|-------|----------|
| Requests to `zinc15.docking.org` redirect to `/captcha/` or return `403` | The legacy ZINC15 REST API is CAPTCHA-walled and unusable from scripts | Switch all calls to `cartblanche22.docking.org` (ZINC22 / CartBlanche) as shown here |
| `GET /substances/{id}.json` (plural) returns HTML | Wrong path — that is the SPA catch-all | Use the **singular** `/substance/{id}.json` |
| Endpoint returns `200` with `content-type: application/json` but body is HTML | CartBlanche serves the SPA shell for unknown routes (misleading content-type) | Check the body actually parses as JSON; only the documented endpoints return data |
| `POST /smiles.json` returns `{"task": ...}` but you expected results | SMILES search is asynchronous | Poll `GET /search/result/{task}` until `status == "SUCCESS"` |
| `400 No Valid SMILES, please try again` | SMILES rejected (or wrong request form) | Validate the SMILES with RDKit; submit via multipart form fields `smiles`/`dist`/`adist`/`database` |
| `KeyError: 'data'` / `'zinc22'` on a successful task | `result` is a dict when there are hits, but an empty list when there are none | Handle both shapes (see `smiles_lookup` helper) |
| Analog search (`dist >= 1`) times out or returns only the exact match | Very large search space on a shared cluster | Use `dist=0` (exact); for similarity use the Smallworld web UI; expand analogs locally with RDKit |
| `/substance/random.json` returns `400`; `/substance/random/{n}.json` returns `{"status":"PENDING"}` | The random endpoint is async/non-trivial | Not needed for scripting; pick known ZINC IDs instead |
| `HTTP 404` for a compound ID | ID typo or not present in this build | Verify the ZINC ID; try `database="zinc20"` for legacy IDs |

## Related Skills

- `rdkit-cheminformatics` — Compute properties and apply PAINS/Brenk filters on retrieved ZINC compounds (required for property filtering here)
- `autodock-vina-docking` — Use exported ZINC SMILES/SDF files for molecular docking campaigns
- `chembl-database-bioactivity` — Bioactivity data for compounds identified in ZINC virtual screens
- `drugbank-database-access` — Approved-drug structures and annotations

## References

- [CartBlanche / ZINC22 web app](https://cartblanche22.docking.org/) — Substance lookup, SMILES search, tranche browser
- [ZINC22 paper](https://doi.org/10.1021/acs.jcim.2c01253) — Tingle et al., "ZINC-22, A Free Multi-Billion-Scale Database of Tangible Compounds for Ligand Discovery," J. Chem. Inf. Model. 2023
- [docking.org wiki](https://wiki.docking.org/) — CartBlanche usage and ZINC22 download documentation
- [files.docking.org](https://files.docking.org/) — Bulk tranche file downloads for ZINC22

