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.

Safety data sheet ingestion and linkage data flow Raw SDS documents from vendors flow into a 16-section GHS parser, which emits candidate field records. Those records are validated into an SDS store keyed on product and revision date. Each container in the chemical inventory is then linked to its current SDS. Containers with a missing or stale sheet are routed to a gaps queue for a compliance officer. Raw SDS docs 16-section parser Validated records Gaps flagged Inventory containers vendor PDFs GHS headings Pydantic SDS model missing · stale link by product Current SDS store versioned by date
Figure: raw vendor sheets become validated, versioned records, and each container is linked to its current SDS while gaps are routed for review.

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.

SafetyDataSheet and Container relationship The SafetyDataSheet entity is keyed on sds_id and holds product_name, cas_number, revision_date, signal_word, h_codes, pictograms, ppe, storage_class and source_version. The Container entity holds container_id, product_name, location and a nullable current_sds_id foreign key. Container has a many-to-one relationship to SafetyDataSheet; a null foreign key marks a gap. N 1 described by SafetyDataSheet Container sds_id product_name cas_number revision_date signal_word h_codes pictograms ppe · storage_class source_version container_id product_name location current_sds_id PK sys PK FK · null=gap
Figure: each container references at most one current SafetyDataSheet; a null foreign key is a compliance gap, not a valid state.
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.

python
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.

python
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_id

Linking 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.

python
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 gaps

Integration 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_id link 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, and storage_class from 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:

json
{
  "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.

  1. Coverage parity. Every container flagged no_sds plus every container carrying a current_sds_id must equal the total container count. A container that is neither linked nor flagged has been silently dropped — a defect.
  2. Reproduce the identity. Re-derive sds_id from a record’s product_name and revision_date and confirm it matches the stored key. A mismatch means the versioning key was tampered with.
  3. Confirm currency. For each product, the linked sheet must be the one with the maximum revision_date present in the store; a container linked to an older version is a stale-SDS finding.
python
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.