Back to Blog
Building a Pathology Lab Management System: Complete Technical Guide

Building a Pathology Lab Management System: Complete Technical Guide

December 16, 2024
9 min read
Tushar Agrawal

Learn how to design and build a modern pathology laboratory management system. Covers sample tracking, test workflows, report generation, billing integration, and compliance requirements for diagnostic labs.

Introduction

Pathology laboratories are the backbone of modern healthcare diagnostics. Having built lab management systems at Dr. Dangs Lab, I understand the unique challenges these facilities face. This guide covers how to build a comprehensive pathology lab management system from the ground up.

Understanding Pathology Lab Workflows

The Sample Journey

┌─────────────────────────────────────────────────────────────────────────────┐
│                         PATHOLOGY LAB WORKFLOW                               │
└─────────────────────────────────────────────────────────────────────────────┘

Patient Collection Transport Lab Reporting Registration Center & Logistics Processing & Delivery │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ┌───────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │Register│────►│ Collect │──────►│Transport│────►│ Process │────►│ Report │ │Patient │ │ Sample │ │ to Lab │ │ & Test │ │ Results │ └───────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ - Demographics - Barcode - Chain of - Queue - PDF Gen - History - Labeling custody - Analyzers - Delivery - Test orders - Requirements - Temperature - QC checks - Archive - Time stamp tracking - Validation

Core Modules

1. Patient Registration Module

from datetime import datetime
from pydantic import BaseModel, Field
from typing import Optional
import uuid

class Address(BaseModel): line1: str line2: Optional[str] = None city: str state: str pincode: str

class Patient(BaseModel): id: str = Field(default_factory=lambda: f"PT{uuid.uuid4().hex[:8].upper()}") name: str phone: str email: Optional[str] = None date_of_birth: datetime gender: str blood_group: Optional[str] = None address: Address emergency_contact: Optional[str] = None created_at: datetime = Field(default_factory=datetime.utcnow)

class PatientService: def __init__(self, db): self.db = db

async def register(self, patient_data: dict) -> Patient: # Check for existing patient existing = await self.db.patients.find_one({ "$or": [ {"phone": patient_data["phone"]}, {"email": patient_data.get("email")} ] })

if existing: return Patient(existing)

patient = Patient(patient_data) await self.db.patients.insert_one(patient.dict()) return patient

async def search(self, query: str) -> list[Patient]: """Search patients by name, phone, or ID""" results = await self.db.patients.find({ "$or": [ {"name": {"$regex": query, "$options": "i"}}, {"phone": {"$regex": query}}, {"id": query.upper()} ] }).to_list(50) return [Patient(p) for p in results]

async def get_history(self, patient_id: str) -> dict: """Get patient's complete test history""" samples = await self.db.samples.find({ "patient_id": patient_id }).sort("collected_at", -1).to_list(100)

return { "patient_id": patient_id, "total_visits": len(samples), "tests": [ { "sample_id": s["id"], "tests": s["tests"], "date": s["collected_at"], "status": s["status"] } for s in samples ] }

2. Test Catalog Management

from enum import Enum
from decimal import Decimal

class SampleType(Enum): BLOOD = "blood" SERUM = "serum" PLASMA = "plasma" URINE = "urine" STOOL = "stool" SWAB = "swab" TISSUE = "tissue" CSF = "csf"

class TestCategory(Enum): HEMATOLOGY = "hematology" BIOCHEMISTRY = "biochemistry" MICROBIOLOGY = "microbiology" IMMUNOLOGY = "immunology" HISTOPATHOLOGY = "histopathology" MOLECULAR = "molecular"

class Test(BaseModel): code: str # e.g., "CBC", "LFT", "KFT" name: str category: TestCategory sample_type: SampleType sample_volume: float # in mL container_type: str # e.g., "EDTA", "Plain", "Fluoride" fasting_required: bool = False turnaround_time: int # in hours price: Decimal parameters: list[str] # Individual test parameters reference_ranges: dict # Age/gender specific ranges method: str # Testing methodology department: str is_active: bool = True

Example test definition

cbc_test = Test( code="CBC", name="Complete Blood Count", category=TestCategory.HEMATOLOGY, sample_type=SampleType.BLOOD, sample_volume=3.0, container_type="EDTA (Purple Top)", fasting_required=False, turnaround_time=4, price=Decimal("450.00"), parameters=[ "Hemoglobin", "RBC Count", "WBC Count", "Platelet Count", "PCV/Hematocrit", "MCV", "MCH", "MCHC", "RDW", "Differential Count (Neutrophils, Lymphocytes, Monocytes, Eosinophils, Basophils)" ], reference_ranges={ "Hemoglobin": { "male_adult": {"min": 13.5, "max": 17.5, "unit": "g/dL"}, "female_adult": {"min": 12.0, "max": 16.0, "unit": "g/dL"}, "child": {"min": 11.0, "max": 14.0, "unit": "g/dL"} }, "WBC Count": { "adult": {"min": 4000, "max": 11000, "unit": "/cumm"} } }, method="Automated Cell Counter", department="Hematology" )

3. Sample Collection and Tracking

from datetime import datetime
from typing import Optional
import qrcode
import io
import base64

class SampleStatus(Enum): REGISTERED = "registered" COLLECTED = "collected" IN_TRANSIT = "in_transit" RECEIVED = "received" PROCESSING = "processing" PENDING_VALIDATION = "pending_validation" VALIDATED = "validated" REPORTED = "reported" DISPATCHED = "dispatched"

class Sample(BaseModel): id: str # Unique barcode/accession number patient_id: str tests: list[str] # Test codes collection_center: str collected_by: str collected_at: datetime sample_type: SampleType container_type: str fasting_status: bool status: SampleStatus = SampleStatus.REGISTERED priority: str = "routine" # routine, urgent, stat special_instructions: Optional[str] = None rejection_reason: Optional[str] = None

class SampleService: def __init__(self, db, barcode_service): self.db = db self.barcode = barcode_service

async def create_sample(self, data: dict) -> Sample: # Generate unique accession number accession = await self._generate_accession_number()

sample = Sample( id=accession, data, status=SampleStatus.REGISTERED, collected_at=datetime.utcnow() )

# Validate sample requirements await self._validate_sample_requirements(sample)

# Store sample await self.db.samples.insert_one(sample.dict())

# Generate barcode label barcode_label = self.barcode.generate(sample)

return sample, barcode_label

async def _generate_accession_number(self) -> str: """Generate unique accession number: YYMMDD-XXXXX""" today = datetime.now().strftime("%y%m%d") count = await self.db.samples.count_documents({ "id": {"$regex": f"^{today}-"} }) return f"{today}-{count + 1:05d}"

async def _validate_sample_requirements(self, sample: Sample): """Validate sample meets test requirements""" for test_code in sample.tests: test = await self.db.tests.find_one({"code": test_code}) if not test: raise ValueError(f"Unknown test: {test_code}")

# Check sample type compatibility if test["sample_type"] != sample.sample_type.value: raise ValueError( f"Test {test_code} requires {test['sample_type']}, " f"got {sample.sample_type.value}" )

# Check fasting requirement if test["fasting_required"] and not sample.fasting_status: raise ValueError(f"Test {test_code} requires fasting")

async def update_status( self, sample_id: str, new_status: SampleStatus, user_id: str, notes: Optional[str] = None ): """Update sample status with audit trail""" sample = await self.db.samples.find_one({"id": sample_id}) if not sample: raise ValueError("Sample not found")

# Create status log entry status_log = { "sample_id": sample_id, "from_status": sample["status"], "to_status": new_status.value, "changed_by": user_id, "changed_at": datetime.utcnow(), "notes": notes } await self.db.status_logs.insert_one(status_log)

# Update sample await self.db.samples.update_one( {"id": sample_id}, {"$set": {"status": new_status.value, "updated_at": datetime.utcnow()}} )

async def reject_sample( self, sample_id: str, reason: str, user_id: str ): """Reject sample with documented reason""" rejection_reasons = [ "Hemolyzed sample", "Lipemic sample", "Insufficient quantity", "Wrong container", "Clotted sample", "Sample leaked", "Patient ID mismatch", "Sample too old" ]

if reason not in rejection_reasons: raise ValueError(f"Invalid rejection reason: {reason}")

await self.db.samples.update_one( {"id": sample_id}, { "$set": { "status": "rejected", "rejection_reason": reason, "rejected_by": user_id, "rejected_at": datetime.utcnow() } } )

# Notify collection center for re-collection await self._notify_recollection(sample_id, reason)

4. Test Processing and Results Entry

class TestResult(BaseModel):
    sample_id: str
    test_code: str
    parameter: str
    value: float | str
    unit: str
    reference_range: dict
    flag: Optional[str] = None  # H, L, C (Critical)
    method: str
    analyzer: Optional[str] = None
    performed_by: str
    performed_at: datetime
    validated_by: Optional[str] = None
    validated_at: Optional[datetime] = None

class ResultService: def __init__(self, db, notification_service): self.db = db self.notifications = notification_service

async def enter_result(self, result_data: dict) -> TestResult: """Enter test result with automatic flagging""" result = TestResult(result_data)

# Auto-calculate flag based on reference range result.flag = self._calculate_flag( result.value, result.reference_range )

# Check for critical values if self._is_critical(result): await self._handle_critical_value(result)

await self.db.results.insert_one(result.dict()) return result

def _calculate_flag(self, value: float, reference: dict) -> Optional[str]: """Calculate result flag based on reference range""" if isinstance(value, str): return None

min_val = reference.get("min") max_val = reference.get("max") critical_low = reference.get("critical_low") critical_high = reference.get("critical_high")

if critical_low and value < critical_low: return "C" # Critical Low if critical_high and value > critical_high: return "C" # Critical High if min_val and value < min_val: return "L" # Low if max_val and value > max_val: return "H" # High return None # Normal

def _is_critical(self, result: TestResult) -> bool: """Check if result is critical and needs immediate attention""" return result.flag == "C"

async def _handle_critical_value(self, result: TestResult): """Handle critical values - immediate notification""" sample = await self.db.samples.find_one({"id": result.sample_id}) patient = await self.db.patients.find_one({"id": sample["patient_id"]})

# Log critical value await self.db.critical_logs.insert_one({ "sample_id": result.sample_id, "patient_id": patient["id"], "test": result.test_code, "parameter": result.parameter, "value": result.value, "timestamp": datetime.utcnow(), "notified": False })

# Notify duty doctor await self.notifications.send_critical_alert( patient=patient, result=result )

async def validate_results( self, sample_id: str, validator_id: str ): """Validate all results for a sample""" # Check all results entered results = await self.db.results.find({ "sample_id": sample_id, "validated_at": None }).to_list(100)

if not results: raise ValueError("No pending results to validate")

# Mark as validated await self.db.results.update_many( {"sample_id": sample_id}, { "$set": { "validated_by": validator_id, "validated_at": datetime.utcnow() } } )

# Update sample status await self.db.samples.update_one( {"id": sample_id}, {"$set": {"status": "validated"}} )

5. Report Generation

from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph
from reportlab.lib.styles import getSampleStyleSheet
import boto3

class ReportGenerator: def __init__(self, db, s3_client): self.db = db self.s3 = s3_client self.styles = getSampleStyleSheet()

async def generate_report(self, sample_id: str) -> str: """Generate PDF report for a sample""" # Fetch all required data sample = await self.db.samples.find_one({"id": sample_id}) patient = await self.db.patients.find_one({"id": sample["patient_id"]}) results = await self.db.results.find({"sample_id": sample_id}).to_list(100)

# Create PDF buffer = io.BytesIO() doc = SimpleDocTemplate(buffer, pagesize=A4) elements = []

# Add header with lab info elements.append(self._create_header())

# Add patient info elements.append(self._create_patient_section(patient))

# Add sample info elements.append(self._create_sample_section(sample))

# Add results table elements.append(self._create_results_table(results))

# Add footer with signatures elements.append(self._create_footer(sample))

doc.build(elements)

# Upload to S3 buffer.seek(0) s3_key = f"reports/{patient['id']}/{sample_id}.pdf" self.s3.upload_fileobj(buffer, 'lab-reports', s3_key)

# Generate signed URL url = self.s3.generate_presigned_url( 'get_object', Params={'Bucket': 'lab-reports', 'Key': s3_key}, ExpiresIn=604800 # 7 days )

return url

def _create_results_table(self, results: list) -> Table: """Create formatted results table""" data = [['Test', 'Result', 'Unit', 'Reference Range', 'Flag']]

for result in results: flag_display = { 'H': '↑ High', 'L': '↓ Low', 'C': '⚠️ Critical', None: '' }.get(result['flag'], '')

ref_range = f"{result['reference_range'].get('min', '')} - {result['reference_range'].get('max', '')}"

data.append([ result['parameter'], str(result['value']), result['unit'], ref_range, flag_display ])

table = Table(data, colWidths=[150, 80, 60, 100, 80]) table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.grey), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 10), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), ('BACKGROUND', (0, 1), (-1, -1), colors.white), ('TEXTCOLOR', (0, 1), (-1, -1), colors.black), ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'), ('FONTSIZE', (0, 1), (-1, -1), 9), ('GRID', (0, 0), (-1, -1), 1, colors.black), ]))

return table

Quality Control

class QualityControl:
    """Quality control for lab operations"""

async def daily_qc_check(self, analyzer_id: str, qc_results: dict): """Record daily QC results for analyzer""" qc_record = { "analyzer_id": analyzer_id, "date": datetime.utcnow().date().isoformat(), "results": qc_results, "status": self._evaluate_qc(qc_results), "recorded_at": datetime.utcnow() }

await self.db.qc_records.insert_one(qc_record)

if qc_record["status"] != "pass": await self._notify_qc_failure(analyzer_id, qc_results)

def _evaluate_qc(self, results: dict) -> str: """Evaluate QC results using Westgard rules""" # Implement Westgard rules # 1:2s, 1:3s, 2:2s, R:4s, 4:1s, 10x pass

Key Takeaways

1. Workflow First: Understand lab workflows before coding 2. Traceability: Every sample needs complete audit trail 3. Quality Control: QC is not optional in pathology 4. Integration: Labs use many analyzers needing integration 5. Compliance: Healthcare regulations are strict 6. Speed Matters: Turnaround time is critical for patient care

Conclusion

Building a pathology lab management system requires deep understanding of laboratory operations, healthcare compliance, and technical excellence. The system must be reliable, traceable, and efficient to support the critical work of diagnostic laboratories.

---

Building laboratory systems? Connect on LinkedIn to discuss pathology technology.

Share this article

Related Articles