Safety Data Sheet management
On this page
A safety data sheet is only useful when a lab worker can reach the current one in seconds and a compliance officer can prove it was current on the day an incident occurred. In most university chemical stores, that proof lives in a shared drive full of PDFs named after whichever vendor sent them, with no link back to the physical containers on the shelf. This guide turns that pile of documents into structured, queryable records: it parses the 16-section GHS format defined by OSHA Hazard Communication, extracts the hazard fields that matter, versions each sheet by its revision date, and links every hazardous container in the inventory to a current SDS — flagging any container whose sheet is missing or stale. It is one of the compliance layers anchored to the parent guide on Hazardous Material & Chemical Inventory Compliance, and it shares its hazard vocabulary with the OSHA Laboratory Standard Automation checks and its container identifiers with EPA RCRA Hazardous Waste Tracking.
University administrators, research compliance officers, Python automation developers, and laboratory managers rely on this subsystem to convert vendor documents into an auditable asset. Once each sheet is a record rather than a file, the chemical inventory can answer questions no folder of PDFs ever could: which containers carry a flammable pictogram, which sheets are older than the manufacturer’s latest revision, and which hazardous chemical on the shelf has no accessible SDS at all.
Problem framing
The Hazard Communication Standard treats the safety data sheet as the primary carrier of hazard information from a chemical manufacturer to the workers who handle the product. That obligation is straightforward to state and surprisingly hard to operationalize. A single research building may hold thousands of containers sourced from dozens of vendors, each of whom formats its sheets slightly differently, revises them on its own schedule, and ships updates by email attachment rather than by any structured feed. The compliance question — is a current SDS readily accessible for every hazardous chemical on hand? — cannot be answered from a folder because a folder has no notion of which sheet is current, which container it describes, or whether a container exists at all with no sheet behind it.
Three properties turn the pile into a system, and the rest of this page implements each:
- Structure over storage. A sheet is parsed into named fields — product identity, CAS number, hazard classes, H-statement codes, signal word, pictograms, handling and storage guidance — so the inventory can query hazards instead of opening documents.
- Version by revision date. Every sheet carries a revision date in Section 16. That date, not the file’s modification time, defines which version is current; a newer manufacturer revision supersedes an older record rather than overwriting the audit history.
- Linkage with gap detection. Each container references the SDS that describes its product. A container with no matching sheet, or one whose sheet predates the manufacturer’s latest revision, is a reportable gap — not a silent absence.
Policy constraints
Compliance is the boundary this layer enforces, not a report it produces afterward. The governing rule is the OSHA Hazard Communication Standard at 29 CFR 1910.1200, which requires employers to maintain a safety data sheet for each hazardous chemical they use and to ensure those sheets are readily accessible to employees in their work area. The standard also fixes the format: paragraph (g) requires the sheet to follow a uniform 16-section order, and Appendix D specifies the minimum content of each section. In a research setting this obligation runs alongside the OSHA Laboratory Standard (29 CFR 1910.1450), whose Chemical Hygiene Plan requirements are automated in the sibling OSHA Laboratory Standard Automation guide; the SDS store is the hazard reference both layers read from.
| Regulatory standard | SDS requirement | Enforcement mechanism |
|---|---|---|
| 29 CFR 1910.1200(g) | A safety data sheet must exist and be readily accessible for every hazardous chemical | Container-to-SDS linkage; unlinked containers flagged as gaps |
| 29 CFR 1910.1200(g)(2) | Sheets must follow the standardized 16-section GHS format | Section segmentation; a record missing mandatory sections is quarantined |
| 29 CFR 1910.1200 App. D | Sections 1–3 must carry identity, hazard classification, and composition/CAS | Field extraction with presence and format validation |
| 29 CFR 1910.1200(g)(5) | The most recent sheet must be maintained when a manufacturer revises it | Versioning keyed on the Section 16 revision date |
| 29 CFR 1910.1450 | Lab hazard controls reference current chemical hazard data | Shared SDS store consumed by the Chemical Hygiene checks |
Operational boundary. Policy dictates that a current sheet must exist, what it must contain, and how accessible it must be; this layer supplies the mechanics — parsing, validation, versioning, and linkage — that make those obligations demonstrable. The parser never invents a hazard classification the sheet does not state, and it never marks a container compliant by defaulting a missing sheet to an empty record. When a sheet cannot be parsed into the mandatory sections, it is quarantined for a human rather than admitted partially. Credential scoping and network isolation for any worker that fetches vendor documents follow the Security Boundary Configuration.
Data schema & field mapping
A safety data sheet record is a versioned compliance artifact. The vendor PDF is the source of truth for content, but the queryable record is what the inventory, the lab hazard checks, and any incident investigation actually consume. The canonical fields below map directly onto the mandatory GHS sections, so every value can be traced to the paragraph of the sheet — and the paragraph of the regulation — it came from.
| Canonical field | Type | Constraint | Source rule |
|---|---|---|---|
sds_id |
str |
system-generated, stable per product+revision | idempotency control |
product_name |
str |
required, non-empty | 29 CFR 1910.1200 App. D Section 1 |
cas_number |
str | None |
^\d{2,7}-\d{2}-\d$, checksum-valid |
App. D Section 3 (composition) |
revision_date |
date |
required, ISO-8601 | App. D Section 16 (revision) |
signal_word |
enum |
{Danger, Warning, None} |
GHS Section 2 signal word |
h_codes |
list[str] |
each ^H\d{3}[A-Za-z]?$ |
GHS Annex III H-statements |
pictograms |
list[str] |
GHS pictogram identifiers | GHS Annex V |
ppe |
list[str] |
free-form controls | App. D Section 8 |
storage_class |
str | None |
mapped storage category | App. D Section 7 |
source_version |
str |
file digest of the parsed document | provenance / non-repudiation |
The sds_id and source_version are the only system-owned fields. Deriving sds_id from the product identity plus the revision date is what makes re-parsing the same vendor sheet idempotent: an unchanged sheet resolves to the same identifier and updates in place, while a newer revision resolves to a new identifier and supersedes its predecessor. The source_version — a digest of the exact bytes parsed — lets an auditor prove a record was derived from a specific document and not edited afterward.
Implementation
The layer has three composable stages: a parser that segments a sheet into its 16 sections and extracts the canonical fields, a Pydantic model that validates those fields against the format rules above, and a linkage pass that binds every container to its current sheet and flags the gaps. The field-by-field extraction — heading segmentation, CAS checksum, H-code patterns, revision-date handling — is developed step by step in the companion how-to, Parsing Safety Data Sheets into Structured Inventory Records; this section shows how the validated records are versioned and linked.
The validated SDS record
The Pydantic model is where the GHS format rules become enforceable. It rejects a CAS number that fails its check digit, an H-code that does not match the H### grammar, and a signal word outside the GHS-permitted set. A sheet whose Section 16 carries no parseable revision date cannot be versioned and is quarantined rather than admitted with a guessed date.
import hashlib
from datetime import date, datetime, timezone
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
class SafetyDataSheet(BaseModel):
"""A validated, versioned SDS record derived from one vendor document."""
model_config = ConfigDict(str_strip_whitespace=True)
product_name: str = Field(..., min_length=1, max_length=300)
cas_number: str | None = Field(default=None)
revision_date: date
signal_word: str = Field(default="None")
h_codes: list[str] = Field(default_factory=list)
pictograms: list[str] = Field(default_factory=list)
ppe: list[str] = Field(default_factory=list)
storage_class: str | None = Field(default=None)
source_version: str # SHA-256 digest of the parsed document bytes
@field_validator("cas_number")
@classmethod
def check_cas(cls, v: str | None) -> str | None:
# A CAS number's final digit is a checksum over the preceding digits,
# so a transcription typo fails here rather than polluting the store.
if v is None:
return v
digits = [int(d) for d in v.replace("-", "")]
check = digits.pop()
total = sum(i * d for i, d in enumerate(reversed(digits), start=1))
if total % 10 != check:
raise ValueError(f"CAS checksum failed for {v!r}")
return v
@field_validator("signal_word")
@classmethod
def check_signal(cls, v: str) -> str:
# GHS permits exactly one of two signal words, or none.
if v not in {"Danger", "Warning", "None"}:
raise ValueError(f"Illegal GHS signal word {v!r}")
return v
@field_validator("h_codes")
@classmethod
def check_h_codes(cls, v: list[str]) -> list[str]:
import re
pattern = re.compile(r"^H\d{3}[A-Za-z]?$")
bad = [c for c in v if not pattern.match(c)]
if bad:
raise ValueError(f"Malformed H-statement codes: {bad}")
return sorted(set(v)) # dedupe + order for a stable fingerprint
@property
def sds_id(self) -> str:
"""Stable identity for this product at this revision."""
key = f"{self.product_name.lower()}|{self.revision_date.isoformat()}"
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:24]Versioning and idempotent upsert
Admitting a sheet is an idempotent upsert keyed on sds_id. A re-parse of the same document resolves to the same identifier and writes nothing new; a manufacturer revision resolves to a new identifier, is inserted, and becomes the current sheet for its product while the prior record is retained for the audit history. The retention of superseded versions is deliberate — 29 CFR 1910.1200(g)(5) requires the current sheet, but an incident investigation frequently needs the sheet that was current on an earlier date.
import logging
from sqlalchemy import String, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
logging.basicConfig(level=logging.INFO, format="%(asctime)s [SDS] %(message)s")
logger = logging.getLogger(__name__)
def upsert_sds(sheet: SafetyDataSheet, session: Session) -> str:
"""Insert a new SDS version idempotently; return its sds_id."""
values = {
"sds_id": sheet.sds_id,
"product_name": sheet.product_name,
"cas_number": sheet.cas_number,
"revision_date": sheet.revision_date,
"signal_word": sheet.signal_word,
"h_codes": sheet.h_codes,
"pictograms": sheet.pictograms,
"ppe": sheet.ppe,
"storage_class": sheet.storage_class,
"source_version": sheet.source_version,
"ingested_at": datetime.now(timezone.utc),
}
stmt = pg_insert(SdsRecord).values(**values)
# An unchanged document (same source_version) writes nothing; a corrected
# parse of the same revision updates in place. A new revision inserts.
stmt = stmt.on_conflict_do_update(
index_elements=["sds_id"],
set_={"source_version": sheet.source_version, "h_codes": sheet.h_codes},
where=(SdsRecord.source_version != sheet.source_version),
)
session.execute(stmt)
session.commit()
logger.info("upserted SDS %s rev %s", sheet.sds_id, sheet.revision_date)
return sheet.sds_idLinking containers and flagging gaps
The linkage pass is where the store becomes a compliance instrument. For each container it resolves the current sheet for that product — the record with the latest revision_date — and writes the reference. A container with no matching sheet, or one whose linked sheet predates the newest available revision, is emitted to a gaps queue with a reason code. Nothing is silently marked compliant.
def relink_containers(session: Session, stale_after_days: int = 365 * 3) -> list[dict[str, Any]]:
"""Link each container to its current SDS; return the gap manifest."""
gaps: list[dict[str, Any]] = []
today = datetime.now(timezone.utc).date()
containers = session.execute(select(Container)).scalars().all()
for c in containers:
current = session.execute(
select(SdsRecord)
.where(SdsRecord.product_name == c.product_name)
.order_by(SdsRecord.revision_date.desc())
.limit(1)
).scalar_one_or_none()
if current is None:
gaps.append({"container_id": c.container_id, "reason": "no_sds"})
c.current_sds_id = None
continue
c.current_sds_id = current.sds_id
age_days = (today - current.revision_date).days
if age_days > stale_after_days:
gaps.append({
"container_id": c.container_id,
"reason": "stale_sds",
"revision_date": current.revision_date.isoformat(),
})
session.commit()
return gapsIntegration points
The SDS store is a shared hazard reference, not an island. It admits records from the parser and publishes two things adjacent systems read: the versioned sheets themselves and the gap manifest.
- Chemical inventory and lab tracking. Containers live in the broader Equipment Calibration & Lab Inventory Tracking domain; this layer supplies the
current_sds_idlink and the hazard fields that let inventory queries filter by pictogram or storage class. - OSHA Laboratory Standard checks. The Automating OSHA Chemical Hygiene Inventory Checks routine reads
h_codes,signal_word, andstorage_classfrom this store to evaluate segregation and control requirements — it does not re-parse sheets. - Ingestion and validation. Vendor documents that arrive through a portal or bulk drop share the deterministic validation discipline of the Schema Validation Pipelines, so a sheet validates identically however it entered.
An example gap manifest published for a container whose sheet is missing:
{
"container_id": "CTR-0442",
"product_name": "Acetonitrile, HPLC grade",
"reason": "no_sds",
"detected_at": "2026-07-15T14:02:00Z"
}Verification & audit
Every ingested sheet appends an entry to an append-only ledger — sds_id, source_version, revision_date, and the ingest timestamp — and every linkage run emits a gap manifest. Together they let any state of the store be reconstructed and verified.
- Coverage parity. Every container flagged
no_sdsplus every container carrying acurrent_sds_idmust equal the total container count. A container that is neither linked nor flagged has been silently dropped — a defect. - Reproduce the identity. Re-derive
sds_idfrom a record’sproduct_nameandrevision_dateand confirm it matches the stored key. A mismatch means the versioning key was tampered with. - Confirm currency. For each product, the linked sheet must be the one with the maximum
revision_datepresent in the store; a container linked to an older version is a stale-SDS finding.
def verify_coverage(session: Session) -> dict[str, int]:
total = session.scalar(select(func.count()).select_from(Container))
linked = session.scalar(
select(func.count()).select_from(Container).where(Container.current_sds_id.is_not(None))
)
unlinked = total - linked
return {"containers": total, "linked": linked, "gaps": unlinked}Because the ledger is append-only and each record carries the digest of the document it came from, an auditor can pin any hazard claim back to the exact sheet, revision, and moment it entered the store. Retain superseded SDS versions for at least the period your institution’s records-retention policy specifies for chemical exposure records; OSHA’s broader recordkeeping rules make the historical sheet, not only the current one, a compliance artifact.
Failure modes & recovery
Vendor sheets are inconsistent, and recovery must never create duplicates or lose the audit history. Each procedure below is idempotent-safe.
| Symptom | Root cause | Idempotent-safe recovery |
|---|---|---|
| Sheet quarantined: no revision date | Section 16 heading uses a non-standard label the segmenter missed | Extend the heading-alias table, re-parse; the same document resolves to one sds_id and upserts without duplication |
ValueError: CAS checksum failed |
OCR transcription error or a genuine vendor typo in Section 3 | Re-OCR at higher fidelity or confirm against the product identity; never store an unverified CAS |
| A container never links despite a sheet existing | product_name differs between the container label and the sheet’s Section 1 |
Add a product-alias mapping keyed to the container, re-run relink_containers; linkage is recomputed idempotently |
| Two “current” sheets for one product | A vendor reissued a sheet with the same revision date but changed content | Disambiguate by source_version; keep both in history, link the later ingest, and record the conflict for review |
Role boundaries. Compliance officers own which sheets are authoritative and adjudicate flagged gaps; they do not edit parser code. Python automation developers own extraction accuracy, idempotency, and linkage logic; they do not decide hazard classifications. Laboratory managers own container-to-product accuracy at the shelf and resolve no_sds findings by obtaining the missing sheet. A container may never be marked compliant to clear a gap queue — the sheet must be obtained and ingested.
Frequently asked questions
Why version safety data sheets by the revision date instead of the file's timestamp?
The file’s modification time reflects when someone last saved or copied the document, not when the manufacturer revised its content. OSHA’s requirement to maintain the most recent sheet is about the manufacturer’s revision, which appears in Section 16. Keying versions on the Section 16 revision date means a sheet re-downloaded unchanged does not masquerade as newer, and a genuine manufacturer update correctly supersedes the prior record while the old version stays in the audit history.
What counts as a compliance gap for a container?
Two conditions. First, a container whose product has no matching sheet in the store at all — the no_sds case — which fails the core 29 CFR 1910.1200(g) accessibility requirement. Second, a container linked to a sheet that is older than the newest revision available for that product, the stale_sds case. Both are emitted to a gap manifest with a reason code so a compliance officer can act; neither is ever silently treated as compliant.
How does safety data sheet management relate to the OSHA Laboratory Standard checks?
They share this store rather than duplicating parsing. This layer is responsible for turning vendor sheets into validated, versioned hazard records and linking them to containers. The Chemical Hygiene checks under the Laboratory Standard read those records — hazard codes, signal word, storage class — to evaluate segregation, control, and inventory requirements. Parsing lives here so that a hazard value has exactly one authoritative source across both compliance layers.
Related
- Parent guide: Hazardous Material & Chemical Inventory Compliance
- Parsing Safety Data Sheets into Structured Inventory Records — the step-by-step extraction how-to
- OSHA Laboratory Standard Automation — the Chemical Hygiene checks that read this hazard store
- EPA RCRA Hazardous Waste Tracking — the disposal side of the same container inventory
- Schema Validation Pipelines — the deterministic validation discipline shared across ingestion