Prescription Reader with Amazon Textract

Read handwritten prescriptions with Amazon Textract and store results in DynamoDB.

Introduction

30 Second Summary

Ever tried reading a doctor's handwriting? Pharmacists do it thousands of times a day, and misreading a single word can mean the wrong medication reaches a patient.

In this project, you will build a Python tool that reads handwritten prescription images using Amazon Textract, extracts structured medical data like patient names and medications, and stores everything in a searchable database.

What You'll Build

Point this tool at any prescription image and watch it identify every handwritten word, pull out specific medical fields by asking natural-language questions, and log the results into a database you can search by medication name.

By the end of this project, you'll have:

  • A working prescription reader that sends handwritten images to Amazon Textract and prints every detected line with a confidence score showing how certain the AI is about each word.
  • A structured extraction layer where you ask Textract natural-language questions like "What is the medication?" and get clean, labelled answers back without writing any prompt engineering.
  • A searchable DynamoDB database storing every prescription you process, queryable by medication name or patient so you can find any record instantly.
  • Secret Mission: Add confidence-based flagging that routes low-confidence prescriptions to a human review queue.

Are there any prerequisites?

You need an AWS account with the AWS CLI configured (credentials set up via aws configure) and Python 3.10+ installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Once you're clear on what you're creating, every step that follows has a purpose.

Set Up Your Python Project

Before you can send prescriptions to Amazon Textract, you need a Python project with the boto3 library installed.

In this step, you will create the project folder, install your dependencies, and write a quick connection test that proves your AWS credentials can reach Textract.

In this step, get ready to:
  • Create your project folder and Python virtual environment.
  • Install boto3 and verify your AWS Textract connection.
  • Download sample prescription images for testing.
Create your project folder and virtual environment
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:

macOS

cd ~/Desktop

Windows

cd ~\Desktop

Linux

cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir prescription-reader
cd prescription-reader
  • Create a Python virtual environment by running:
python3 -m venv .venv

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. It keeps this project's dependencies separate from other Python projects on your machine.

  • Activate the virtual environment:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\activate

You should see (.venv) appear at the start of your terminal prompt, confirming the environment is active.

  • Install boto3 (the AWS SDK for Python) by running:
pip install boto3

You should see output ending with Successfully installed followed by package names including boto3.

Seeing a permission error or command not found?

  • Make sure you activated the virtual environment first. You should see (.venv) in your terminal prompt.
  • On macOS, try python3 instead of python if the command is not found.

help me fix a pip install error in my virtual environment

Create your connection test script

Now you will create a Python script that verifies your AWS credentials can reach Textract. The trick is to send an intentionally invalid image. If Textract responds with an UnsupportedDocumentException, that proves your credentials work.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the prescription-reader folder on your Desktop, then click Open.
  • In the Explorer sidebar, click the new file icon next to PRESCRIPTION-READER.
  • Name the file main.py and press Enter.
  • Add the imports and setup at the top of main.py by pasting this code:
import os
import sys
import sqlite3
from datetime import datetime

import boto3
from botocore.exceptions import ClientError

# AWS Textract client
textract = boto3.client("textract")

# Database file path
DATABASE = "prescriptions.db"

What does this code do?

  • boto3.client("textract") creates a Textract client that uses the AWS credentials you configured with aws configure.
  • DATABASE stores the path where your prescription database will live. You will use it in a later step.
  • The other imports (os, sys, sqlite3) are set up now so you do not need to revisit imports later.
  • Below the imports, add the connection verification function and the entry point by pasting this code:
def verify_connection():
    """Verify AWS credentials can reach Textract."""
    try:
        textract.detect_document_text(
            Document={"Bytes": b"\x89PNG\r\n\x1a\n" + b"\x00" * 100}
        )
    except ClientError as e:
        error_code = e.response["Error"]["Code"]
        if error_code == "UnsupportedDocumentException":
            print("AWS connection verified. Textract is ready.")
            return True
        elif error_code in ("AccessDeniedException", "InvalidSignatureException"):
            print(f"AWS credentials error: {error_code}")
            print("Run 'aws configure' to set up your credentials.")
            return False
        else:
            print("AWS connection verified. Textract is ready.")
            return True
    except Exception as e:
        print(f"Connection error: {e}")
        return False
    print("AWS connection verified. Textract is ready.")
    return True


if __name__ == "__main__":
    if not verify_connection():
        sys.exit(1)

What does this code do?

  • verify_connection() sends a tiny fake PNG to Textract. The image is invalid on purpose.
  • If Textract responds with UnsupportedDocumentException, that means your credentials worked and the request reached the service. Connection confirmed.
  • If you get AccessDeniedException, your credentials are missing or invalid.
  • The if __name__ == "__main__" block runs the verification when you execute the script directly.
  • Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py.

ⓧ I'd like to double check the full code

Your main.py should contain two sections in this order:

  • Chunk 1 (imports and setup): import os through DATABASE = "prescriptions.db".
  • Chunk 2 (connection test): def verify_connection(): through the if __name__ == "__main__" block at the bottom.

Scroll up and confirm both chunks are present in order with no extra code between them.

Before you run this, what do you think will happen? Will you see a success message or an error?

  • Switch back to your terminal and run the script:
python main.py

You should see this output:

AWS connection verified. Textract is ready.

Seeing an AccessDeniedException or credentials error?

  • Run aws configure and confirm your Access Key ID, Secret Access Key, and region are set correctly.
  • Make sure your IAM user has the AmazonTextractFullAccess policy attached.
  • Confirm your virtual environment is still active. You should see (.venv) in your prompt.

help me fix my AWS Textract credentials error when running my Python script

Download sample prescription images

You need sample prescription images to test your reader. A repository with handwritten prescription samples is available on GitHub.

  • Download the sample prescriptions by running this command from inside your prescription-reader folder:
git clone https://github.com/gjoshius/prescription-samples.git temp-samples
  • Create your prescriptions folder and copy the PNG files into it by running:

macOS

mkdir prescriptions
cp temp-samples/png/* prescriptions/
rm -rf temp-samples

Windows

mkdir prescriptions
copy temp-samples\png\* prescriptions\
rmdir /s /q temp-samples

Linux

mkdir prescriptions
cp temp-samples/png/* prescriptions/
rm -rf temp-samples

What does this do?

You clone the sample repository into a temporary folder, copy just the PNG prescription images into a clean prescriptions/ folder, then delete the temporary clone. This gives you only the files you need.

  • Confirm the images are in place by running:

macOS

ls prescriptions

Windows

dir prescriptions

Linux

ls prescriptions

You should see prescription_01.png, prescription_02.png, and prescription_03.png listed.

Seeing 'git: command not found'?

  • On macOS, running any git command for the first time may prompt you to install Xcode Command Line Tools. Accept the prompt and try again.
  • On Windows, download Git from git-scm.com and restart your terminal after installing.

help me install git so I can clone a repository

Your environment is ready and Textract is responding. Next up, you will send a real prescription image and see what Textract can read from handwriting.

Read a Prescription with Raw OCR

In the previous step you confirmed that your Python project can reach Amazon Textract. Now it's time to send a real prescription image and see what comes back.

Textract's DetectDocumentText API reads every word in an image and returns per-word confidence scores. It also identifies whether each word is handwritten or printed. This step gives you your first visible proof that Textract can read handwriting.

In this step, get ready to:
  • Write functions that read an image file and send it to Textract's DetectDocumentText API.
  • Parse the response to display each detected line with its confidence score and text type.
  • Run the script against a sample prescription and observe the raw output.
Write the image reading and detection functions

To get Textract to read a prescription, you need to load the image file as raw bytes and send those bytes to the API. You'll split this into two small functions so the file-reading logic stays reusable.

  • In main.py, find the closing line of the verify_connection() function (return True at the bottom of it).
  • Add the following two functions below verify_connection(), before the if __name__ == "__main__": block:
def read_file(file_path):
    """Read a file and return raw bytes."""
    with open(file_path, "rb") as f:
        return f.read()


def detect_text(file_path):
    """Send an image to Textract DetectDocumentText and return blocks."""
    image_bytes = read_file(file_path)
    response = textract.detect_document_text(Document={"Bytes": image_bytes})
    return response.get("Blocks", [])

What does this code do?

You have two functions working together:

  • read_file() opens any file in binary mode ("rb") and returns its raw bytes. Textract needs image data as bytes, not a file path.
  • detect_text() passes those bytes to Textract's detect_document_text API and returns the response's Block objects. Each Block represents a detected element (word, line, or page).
  • Save main.py.
Parse and display detected lines

Textract returns many Block types (PAGE, LINE, WORD). For a readable overview of what's on the prescription, you want the LINE blocks. Each LINE block includes a TextType field that tells you whether Textract detected the text as HANDWRITING or PRINTED.

  • Add this function below detect_text(), still above the if __name__ == "__main__": block:
def print_raw_ocr(blocks):
    """Print detected lines with confidence and text type."""
    for block in blocks:
        if block.get("BlockType") == "LINE":
            text = block.get("Text", "")
            confidence = block.get("Confidence", 0)
            text_type = block.get("TextType", "UNKNOWN")
            print(f"  [{text_type}] {text} (confidence: {confidence:.1f}%)")

What does this code do?

  • The function loops through every Block in Textract's response and filters for LINE blocks only. Lines give you the full text of each detected row on the page.
  • For each line, it prints the TextType (HANDWRITING or PRINTED), the detected text itself, and the confidence score as a percentage.
  • The confidence score tells you how certain Textract is about each detection. Higher percentages mean Textract is more sure of what it read.
  • Save main.py.
Run the script against a sample prescription

Your functions are ready but nothing calls them yet. You need to update the main block to load a sample prescription image, run detection, and print the results.

  • In main.py, find the if __name__ == "__main__": block at the bottom of the file. It currently looks like this:
if __name__ == "__main__":
    if not verify_connection():
        sys.exit(1)
  • Replace that entire block with the following code, which adds the detection calls after the connection check:
if __name__ == "__main__":
    if not verify_connection():
        sys.exit(1)
    sample = os.path.join("prescriptions", "prescription_01.png")
    print("\n  --- Raw OCR Detection ---")
    blocks = detect_text(sample)
    print_raw_ocr(blocks)

What does this code do?

  • os.path.join("prescriptions", "prescription_01.png") builds the file path to your sample image in a way that works on both macOS and Windows.
  • detect_text(sample) sends the image to Textract and returns the Block objects.
  • print_raw_ocr(blocks) filters for LINE blocks and prints each one with its confidence and text type.
  • Save main.py.

Before you run this, think about what you expect to see. Will Textract return structured field labels like "Patient Name" and "Medication"? Or just the raw text it found on the page?

  • Run your script by entering this command in the terminal:
python main.py

You should see the connection verification message followed by each detected line printed with its text type and confidence score. Something like:

AWS connection verified. Textract is ready.

  --- Raw OCR Detection ---
  [PRINTED] Dr. Sarah Johnson (confidence: 99.2%)
  [PRINTED] Medical License: 12345 (confidence: 98.7%)
  [HANDWRITING] John Smith (confidence: 87.3%)
  [HANDWRITING] Amoxicillin 500mg (confidence: 82.1%)

What should I see?

Your exact output depends on the sample image, but you should see a mix of HANDWRITING and PRINTED text types. Printed headers (doctor name, clinic address) tend to have higher confidence scores. Handwritten fields (patient name, medication) typically show lower confidence.

Notice that the output is just raw lines of text. There are no labels telling you which line is the patient name, which is the medication, or which is the dosage. That's the limitation of raw OCR. You can see every word on the page, but you'd have to manually figure out what each line means.

🙋‍♀️ Seeing a FileNotFoundError?

  • Make sure you have a file named prescription_01.png inside the prescriptions/ folder. Check the spelling matches exactly.
  • Confirm your terminal is inside the prescription-reader folder (not the prescriptions/ subfolder).
  • Help me debug this FileNotFoundError in my Textract prescription reader script.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and move on to the next step.

ⓧ I'd like to double check the full code

Your main.py should contain these sections in this order. Scroll up and confirm each one is present:

  • Imports at the top (os, sys, sqlite3, datetime, boto3, ClientError).
  • The textract client and DATABASE constant.
  • verify_connection() function (from Step 1).
  • read_file() function.
  • detect_text() function.
  • print_raw_ocr() function.
  • The if __name__ == "__main__": block that calls verify_connection(), then detect_text(), then print_raw_ocr().

You just proved that Textract can read handwriting and printed text from a prescription image. But look at that output. It's just lines of text with no labels. You can't programmatically answer "what medication was prescribed?" from a wall of detected lines. In the next step, you'll fix that by asking Textract targeted questions about the document.

Extract Structured Data with Queries

In the previous step, you saw Textract detect every line of text on a prescription. That raw output is impressive, but it is unstructured.

You cannot programmatically answer "what medication was prescribed?" from a wall of detected lines. Textract's Queries feature solves this. You ask natural-language questions and get targeted answers with confidence scores, no prompt engineering needed.

In this step, get ready to:
  • Write a function that sends natural-language questions to Textract's AnalyzeDocument API.
  • Parse the QUERY and QUERY_RESULT blocks to extract labelled answers with confidence scores.
  • Run the script and see structured key-value output for each prescription field.
Write the Queries function

Textract Queries let you ask questions like "What is the patient name?" directly against a document. Textract returns the answer it finds along with a confidence score.

  • In main.py, find the print_raw_ocr function. Add the following new function below it:
def extract_with_queries(file_path):
    """Use Textract Queries to extract structured prescription fields."""
    image_bytes = read_file(file_path)

    # Natural-language questions to ask about the prescription
    queries = [
        {"Text": "What is the patient name?", "Alias": "patient_name"},
        {"Text": "What is the prescriber name?", "Alias": "prescriber_name"},
        {"Text": "What is the medication?", "Alias": "medication"},
        {"Text": "What is the dosage?", "Alias": "dosage"},
        {"Text": "What is the frequency?", "Alias": "frequency"},
    ]

    # Send the image with queries to Textract AnalyzeDocument
    response = textract.analyze_document(
        Document={"Bytes": image_bytes},
        FeatureTypes=["QUERIES"],
        QueriesConfig={"Queries": queries},
    )

What does this code do?

Each query has a Text field (the question in natural language) and an Alias (a short label you use to retrieve the answer later).

The analyze_document call sends the image bytes plus the five questions to Textract. Setting FeatureTypes=["QUERIES"] tells Textract to run its Queries engine instead of plain OCR.

💡 Why Queries instead of prompt engineering?

With a general-purpose LLM, you would write a prompt asking it to extract fields from the OCR text. Textract Queries are purpose-built for this. You get a dedicated extraction model, per-field confidence scores, and zero prompt tuning.

  • Save main.py. The function is not called yet. The next substep adds the response parsing that completes it.
Parse the QUERY and QUERY_RESULT blocks

Textract returns its answers as linked Block objects. Each QUERY block links to a QUERY_RESULT block via a Relationship of type ANSWER.

  • Still inside the extract_with_queries function, add the following code directly below the response = textract.analyze_document(...) call:
    blocks = response.get("Blocks", [])

    # Build a map of block IDs to blocks
    block_map = {b["Id"]: b for b in blocks}

    # Find QUERY blocks and their linked QUERY_RESULT blocks
    results = {}
    for block in blocks:
        if block.get("BlockType") == "QUERY":
            query_alias = block.get("Query", {}).get("Alias", "")
            # Find the linked QUERY_RESULT via Relationships
            for rel in block.get("Relationships", []):
                if rel.get("Type") == "ANSWER":
                    for result_id in rel.get("Ids", []):
                        result_block = block_map.get(result_id, {})
                        if result_block.get("BlockType") == "QUERY_RESULT":
                            results[query_alias] = {
                                "text": result_block.get("Text", ""),
                                "confidence": result_block.get("Confidence", 0),
                            }

    return results

What does this code do?

The block_map dictionary lets you look up any block by its ID. This is how you follow the links between QUERY and QUERY_RESULT blocks.

The loop finds every QUERY block, reads its Alias (like patient_name), then follows the ANSWER relationship to the linked QUERY_RESULT. From there it grabs the answer text and confidence score.

The function returns a dictionary mapping each alias to its extracted text and confidence. This gives you clean, labelled data from a single function call.

  • Save main.py. Your extract_with_queries function is now complete.

Seeing indentation errors?

The parsing code must be indented inside the extract_with_queries function (one level of indentation, 4 spaces). Make sure the blocks = response.get(...) line aligns with the response = textract.analyze_document(...) line above it.

help me fix indentation in my extract_with_queries function

Run against the sample prescription

The function is ready. Now you need to call it from the main block and print the structured results.

  • In main.py, scroll to the bottom and find this line inside the if __name__ == "__main__" block:
    print_raw_ocr(blocks)
  • Add the following code directly below that line to call your new function and print structured results:
    print("\n  --- Structured Extraction (Queries) ---")
    data = extract_with_queries(sample)
    for field, result in data.items():
        print(f"  {field}: {result['text']} (confidence: {result['confidence']:.1f}%)")

What does this code do?

This calls extract_with_queries on the same sample image and prints each field with its extracted value and confidence percentage. You see both the raw OCR and the structured extraction in one run.

Before you run this, what do you expect to see differently from the raw OCR output in Step 2?

  • Save main.py and run your script:
python main.py

You should see the raw OCR output first (same as before), followed by a new section with labelled key-value results like:

  --- Structured Extraction (Queries) ---
  patient_name: John Smith (confidence: 94.2%)
  prescriber_name: Dr. Jane Williams (confidence: 91.8%)
  medication: Amoxicillin (confidence: 96.5%)
  dosage: 500mg (confidence: 93.1%)
  frequency: Three times daily (confidence: 88.7%)

Why is this better than raw OCR?

The raw OCR gave you every line of text with no labels. Now you have named fields with per-field confidence scores. You can programmatically check "what medication was prescribed?" without parsing unstructured text.

🙋‍♀️ Getting an InvalidParameterException?

If you see an error mentioning QueriesConfig must be used with QUERIES FeatureType, double-check that FeatureTypes=["QUERIES"] is spelled exactly as shown (uppercase, inside a list).

If you see an error about QueriesConfig being an unknown parameter, your boto3 version may be outdated. Run pip install --upgrade boto3 to update.

help me fix my Textract AnalyzeDocument Queries call

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py and that both the raw OCR section and the structured extraction section print when you run the script.

ⓧ I'd like to double check the full code

Your main.py should now contain these sections in order. Scroll through and confirm each is present:

  • Imports (os, sys, sqlite3, datetime, boto3, ClientError).
  • The textract client and DATABASE constant.
  • The verify_connection() function.
  • The read_file() function.
  • The detect_text() function.
  • The print_raw_ocr() function.
  • The new extract_with_queries() function (queries list, analyze_document call, block parsing, and return results).
  • The if __name__ == "__main__" block that runs verify_connection(), prints raw OCR, then prints structured extraction results.

Your script now extracts structured, labelled data from prescriptions. Next up, you will store these results in a database so they are permanent and searchable.

Store in SQLite and Query Your Data

Store in DynamoDB and Query Your Data

Your extraction pipeline prints structured prescription data to the terminal. The moment you close that window, everything disappears.

In this step, you will store every prescription you process in Amazon DynamoDB, a fully managed cloud database. Once stored, you can query your data by medication name or patient without re-running Textract.

In this step, get ready to:
  • Create a DynamoDB table and an insert function that stores extracted prescriptions.
  • Add scan and search functions to retrieve stored records.
  • Wire everything together with CLI flags for processing, querying, and searching.
Create the DynamoDB table and insert function

DynamoDB is a serverless key-value database managed by AWS. No server to install, no file to manage. You create a table with a primary key and interact with it using the boto3 resource interface.

  • In main.py, find the imports at the top of the file. Add these two lines below your existing imports:
from boto3.dynamodb.conditions import Attr
from decimal import Decimal

What does this code do?

Attr lets you build filter expressions for DynamoDB scans (similar to SQL WHERE clauses). Decimal is required because DynamoDB stores numbers as Decimal, not Python floats.

  • Next, find the line that says DATABASE = "prescriptions.db". Replace it with these two lines:
TABLE_NAME = "prescriptions"
# DynamoDB resource (reuses credentials from aws configure)
dynamodb = boto3.resource('dynamodb')
  • Now replace the init_database function with this init_table function:
def init_table():
    """Create the prescriptions table if it does not exist."""
    try:
        table = dynamodb.Table(TABLE_NAME)
        table.load()
        print(f"  Table '{TABLE_NAME}' ready.")
    except ClientError as e:
        if e.response['Error']['Code'] == 'ResourceNotFoundException':
            print(f"  Creating table '{TABLE_NAME}'...")
            table = dynamodb.create_table(
                TableName=TABLE_NAME,
                KeySchema=[
                    {'AttributeName': 'prescription_id', 'KeyType': 'HASH'}
                ],
                AttributeDefinitions=[
                    {'AttributeName': 'prescription_id', 'AttributeType': 'S'}
                ],
                ProvisionedThroughput={
                    'ReadCapacityUnits': 5,
                    'WriteCapacityUnits': 5
                }
            )
            table.wait_until_exists()
            print(f"  Table '{TABLE_NAME}' created.")
        else:
            raise
    return table

What does this code do?

table.load() checks if the table already exists. If it does, the function prints a ready message. If DynamoDB returns ResourceNotFoundException, the function creates the table with a string partition key called prescription_id.

wait_until_exists() pauses until the table status changes from CREATING to ACTIVE. This takes about 10 seconds the first time you run the script. After that, the table already exists and the check is instant.

  • Now replace the store_prescription function with this DynamoDB version:
def store_prescription(data, source_file):
    """Store extracted prescription data in DynamoDB."""
    table = dynamodb.Table(TABLE_NAME)
    # Create a unique ID from filename and timestamp
    prescription_id = f"{os.path.basename(source_file)}_{datetime.now().isoformat()}"
    table.put_item(
        Item={
            'prescription_id': prescription_id,
            'patient_name': data.get('patient_name', {}).get('text', ''),
            'prescriber_name': data.get('prescriber_name', {}).get('text', ''),
            'medication': data.get('medication', {}).get('text', ''),
            'dosage': data.get('dosage', {}).get('text', ''),
            'frequency': data.get('frequency', {}).get('text', ''),
            'patient_name_confidence': Decimal(str(round(
                data.get('patient_name', {}).get('confidence', 0), 1))),
            'medication_confidence': Decimal(str(round(
                data.get('medication', {}).get('confidence', 0), 1))),
            'source_file': source_file,
            'extracted_at': datetime.now().isoformat()
        }
    )
    patient = data.get('patient_name', {}).get('text', 'Unknown')
    print(f"  Stored prescription for {patient} in DynamoDB.")

What does this code do?

put_item() inserts a new item into the table. The Item dictionary maps attribute names to values using native Python types (strings, Decimals). DynamoDB handles the type conversion automatically.

The prescription_id combines the filename and timestamp to create a unique key for each extraction. Confidence scores are wrapped in Decimal() because DynamoDB does not accept Python floats directly.

  • Save main.py.
  • Wire both functions into your existing code. In the if __name__ == "__main__": block, replace init_database() with init_table() so the bottom of your file looks like this:
if __name__ == "__main__":
    if not verify_connection():
        sys.exit(1)
    init_table()
    sample = os.path.join("prescriptions", "prescription_01.png")
    print("\n  --- Raw OCR Detection ---")
    blocks = detect_text(sample)
    print_raw_ocr(blocks)
    print("\n  --- Structured Extraction (Queries) ---")
    data = extract_with_queries(sample)
    for field, result in data.items():
        print(f"  {field}: {result['text']} (confidence: {result['confidence']:.1f}%)")
    store_prescription(data, sample)
  • Save main.py and run the script to test your DynamoDB functions:
python main.py

The first run takes about 10 seconds while DynamoDB creates the table. You should see Creating table 'prescriptions'... followed by Table 'prescriptions' created. then the normal Textract output, and finally Stored prescription for [patient name] in DynamoDB.

Getting an AccessDeniedException?

  • Your AWS credentials need DynamoDB permissions. If you are using an IAM user, attach the AmazonDynamoDBFullAccess managed policy to your user in the IAM console.
  • Confirm your region matches by running aws configure get region. The table is created in whatever region your CLI is configured for.
  • help me fix a DynamoDB AccessDeniedException when running my prescription reader
Add scan and search functions

Data in a database is only useful if you can retrieve it. You need two retrieval functions: one to display all stored records and one to search by medication name.

  • In main.py, add the query_all function below store_prescription:
def query_all():
    """Display all prescriptions stored in DynamoDB."""
    table = dynamodb.Table(TABLE_NAME)
    response = table.scan()
    items = response['Items']

    # Handle pagination for large tables
    while 'LastEvaluatedKey' in response:
        response = table.scan(ExclusiveStartKey=response['LastEvaluatedKey'])
        items.extend(response['Items'])

    if not items:
        print("No prescriptions in database.")
        return

    print(f"\n{'=' * 60}")
    print(f"{'PRESCRIPTIONS DATABASE':^60}")
    print(f"{'=' * 60}")
    for item in items:
        print(f"\n  Patient: {item.get('patient_name', 'N/A')}")
        print(f"  Prescriber: {item.get('prescriber_name', 'N/A')}")
        print(f"  Medication: {item.get('medication', 'N/A')}")
        print(f"  Dosage: {item.get('dosage', 'N/A')}")
        print(f"  Frequency: {item.get('frequency', 'N/A')}")
        print(f"  Source: {item.get('source_file', 'N/A')}")
        print(f"  {'-' * 56}")
    print(f"\nTotal: {len(items)} prescription(s)")

What does this code do?

table.scan() reads every item in the table. DynamoDB returns at most 1 MB per request, so the while loop handles pagination by following LastEvaluatedKey until all items are retrieved.

Each item is a Python dictionary with attribute names as keys. The .get('field', 'N/A') pattern provides a fallback if an attribute is missing from a particular record.

  • Add a query_all() call at the very end of your if __name__ == "__main__": block (after store_prescription). Add print("\n") before it for spacing:
    store_prescription(data, sample)
    print("\n")
    query_all()
  • Save main.py and run it:
python main.py

After the Textract output, you should see a formatted table with a header reading PRESCRIPTIONS DATABASE and at least one stored record showing the patient name, medication, and other fields.

Seeing an empty database message?

  • Make sure store_prescription(data, sample) appears before query_all() in your __name__ block. The record must be inserted before you scan.
  • Check that init_table() runs without errors. If the table was not created, the scan will find nothing.
  • help me figure out why query_all shows no records in DynamoDB
  • Now add the search_by_medication function below query_all:
def search_by_medication(medication_name):
    """Search prescriptions by medication name."""
    table = dynamodb.Table(TABLE_NAME)
    # Use contains() for partial matching (like SQL LIKE '%...%')
    response = table.scan(
        FilterExpression=Attr('medication').contains(medication_name)
    )
    items = response['Items']

    if not items:
        print(f"No prescriptions found containing '{medication_name}'.")
        return

    print(f"\nSearch results for '{medication_name}':")
    for item in items:
        print(f"  - {item.get('patient_name', 'N/A')}: "
              f"{item.get('medication', 'N/A')} ({item.get('dosage', 'N/A')})")

What does this code do?

Attr('medication').contains(medication_name) performs a case-sensitive substring match on the medication attribute. Searching for "amox" matches "Amoxicillin 500mg" if the stored value contains that substring. This is DynamoDB's equivalent of SQL's LIKE '%amox%'.

  • Save main.py.
Wire everything together with CLI flags

Your functions are ready but the __name__ block still has inline code from the previous step. You will refactor it into a clean CLI with dedicated flags for each action.

  • First, add a process_single function below search_by_medication. This wraps OCR, extraction, and storage into one reusable call:
def process_single(file_path):
    """Process a single prescription image."""
    print(f"\nProcessing: {file_path}")

    # Raw OCR detection
    print("\n  --- Raw OCR Detection ---")
    blocks = detect_text(file_path)
    print_raw_ocr(blocks)

    # Structured extraction with Queries
    print("\n  --- Structured Extraction (Queries) ---")
    data = extract_with_queries(file_path)
    for field, result in data.items():
        print(f"  {field}: {result['text']} (confidence: {result['confidence']:.1f}%)")

    # Store in DynamoDB
    store_prescription(data, file_path)
    return data

What does this code do?

This function combines the three pipeline stages (raw OCR, structured queries, DynamoDB storage) into a single call. It returns the extracted data dictionary so callers can use the results further.

  • Now replace everything from if __name__ == "__main__": to the end of the file with this main() function and updated entry point:
def main():
    init_table()

    if len(sys.argv) < 2:
        # Default: process single sample prescription
        sample = os.path.join("prescriptions", "prescription_01.png")
        if not os.path.exists(sample):
            print("No sample found in prescriptions/ folder.")
            return
        process_single(sample)
        print("\n")
        query_all()

    elif sys.argv[1] == "--search" and len(sys.argv) > 2:
        search_by_medication(sys.argv[2])

    elif sys.argv[1] == "--query":
        query_all()

    else:
        print("Usage:")
        print("  python main.py              Process sample prescription")
        print("  python main.py --search <med>  Search by medication name")
        print("  python main.py --query      Show all stored prescriptions")


if __name__ == "__main__":
    if not verify_connection():
        sys.exit(1)
    main()

What does this code do?

The main() function initializes the DynamoDB table, then checks sys.argv to decide what to do. With no arguments, it processes the sample prescription and displays all stored records.

The --query flag shows all stored records without processing anything new. The --search flag finds prescriptions matching a medication name. Any unrecognized input prints the usage guide.

  • Save main.py.

What do you expect to see when you run the script with no arguments? Think about what the default branch does before you run it.

  • Run the script with no arguments:
python main.py

You should see Table 'prescriptions' ready. (since the table already exists), then the Textract output, Stored prescription for [patient name] in DynamoDB., followed by the PRESCRIPTIONS DATABASE table showing your stored records.

  • Now test the --query flag by running:
python main.py --query

You should see the same formatted table of stored prescriptions without any new Textract calls.

  • Finally, test the search function by running:
python main.py --search amoxicillin

If a stored prescription contains "amoxicillin" (or similar), you will see a matching result. If not, try searching for a medication name you saw in the Textract output above.

Getting a usage message instead of results?

  • Make sure you deleted the old if __name__ == "__main__": block completely before pasting the new main() function and entry point. Duplicate blocks cause unexpected behaviour.
  • Check that process_single is defined above main() in the file. Python reads top to bottom, so functions must be defined before they are called.
  • help me debug why my CLI flags are not working in main.py

Starting fresh with DynamoDB

Each run inserts a new record. If you want to reset your data, delete all items from the table using the AWS Console (select all items, then Actions > Delete items), or delete and recreate the table by running:

aws dynamodb delete-table --table-name prescriptions

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py with all the new functions in place.

ⓧ I'd like to double check the full code

Scroll up through your file and confirm each of these sections is present, in order:

  • Imports (os, sys, Decimal, datetime, boto3, ClientError, Attr).
  • Textract client, TABLE_NAME constant, and dynamodb resource.
  • init_table() function.
  • verify_connection() function.
  • read_file() function.
  • detect_text() function.
  • print_raw_ocr() function.
  • extract_with_queries() function.
  • store_prescription() function.
  • query_all() function.
  • search_by_medication() function.
  • process_single() function.
  • main() function with --search and --query branches.
  • if __name__ == "__main__": block calling verify_connection() then main().

Your prescriptions now live in a permanent, cloud-hosted DynamoDB table. Next up, you will process multiple prescriptions at once and see how confidence varies across different handwriting styles.

Batch Process Multiple Prescriptions

Your pipeline can now read a single prescription, extract structured data, and store it in a database. That proves the concept for one document.

In a real clinic, you would process stacks of prescriptions at once. Running the pipeline against a folder of images exposes confidence variation across different handwriting styles and proves you can scale beyond a single file.

In this step, get ready to:
  • Write a batch function that finds and processes all prescription images in a folder.
  • Add summary reporting with confidence statistics across all processed prescriptions.
  • Wire the --batch CLI flag and review confidence distribution across different handwriting styles.
Write the batch processing function

The batch processor needs to walk a folder, find every supported image file, and run each one through your existing process_single() pipeline. It also needs to handle failures gracefully so one bad image does not crash the entire batch.

  • In main.py, add the following function above your main() function definition. Start with the file discovery section:
def process_batch(folder="prescriptions", threshold=None):
    """Process all image files in a folder."""
    if not os.path.exists(folder):
        print(f"Folder '{folder}' not found.")
        return

    # Find all supported image files in the folder
    supported_ext = (".png", ".jpg", ".jpeg")
    files = []
    for root, dirs, filenames in os.walk(folder):
        for filename in filenames:
            if filename.lower().endswith(supported_ext):
                files.append(os.path.join(root, filename))

    if not files:
        print(f"No image files found in '{folder}'.")
        return

What does this code do?

  • os.walk(folder) recursively traverses the folder and all subfolders, yielding every file it finds.
  • supported_ext limits the batch to image formats that Textract accepts for synchronous operations.
  • Early returns handle the "folder missing" and "no images found" edge cases before any API calls happen.
  • Still inside process_batch, add the processing loop and summary reporting directly below the return line you just wrote:
    print(f"\nBatch processing {len(files)} prescription(s)...\n")
    # Track successes, failures, and confidence scores
    successes = 0
    failures = []
    all_confidences = []

    # Process each prescription image
    for file_path in sorted(files):
        try:
            data = process_single(file_path)
            if data:
                successes += 1
                confidences = [v["confidence"] for v in data.values() if v.get("confidence")]
                all_confidences.extend(confidences)
            else:
                failures.append(file_path)
        except Exception as e:
            print(f"  Error processing {file_path}: {e}")
            failures.append(file_path)

    # Print batch summary
    print(f"\nBatch complete: {successes} succeeded, {len(failures)} failed.")
    if all_confidences:
        avg = sum(all_confidences) / len(all_confidences)
        print(f"Average confidence across all fields: {avg:.1f}%")
    if failures:
        print(f"Failed files: {', '.join(failures)}")

What does this code do?

  • The loop calls process_single() on each image. That function already handles raw OCR, structured extraction, and database storage.
  • The try/except block catches any single-file failure (corrupted image, API timeout) without stopping the rest of the batch.
  • all_confidences collects every field's confidence score across all prescriptions so you can compute the average at the end.
  • Save main.py.

Seeing an indentation error?

The second code block must be indented at the same level as the rest of the function body (4 spaces). Make sure it sits directly below the return line at the end of your first block, still inside process_batch.

Help me fix an indentation error in my process_batch function.

Wire the --batch CLI flag

The function exists, but the learner has no way to trigger it from the command line yet. You need to add a --batch flag to main().

  • In your main() function, find this line:
    elif sys.argv[1] == "--search" and len(sys.argv) > 2:
  • Add this --batch block directly above that line:
    elif sys.argv[1] == "--batch":
        threshold = None
        if "--threshold" in sys.argv:
            idx = sys.argv.index("--threshold")
            if idx + 1 < len(sys.argv):
                threshold = float(sys.argv[idx + 1])
        process_batch(threshold=threshold)
        print("\n")
        query_all()

What does this code do?

  • When the user runs python main.py --batch, this branch calls process_batch() then query_all() to show the full database after processing.
  • The --threshold argument is parsed here but not used yet. You will use it in the Secret Mission to flag low-confidence extractions.
  • Finally, find the else block at the bottom of main() that prints usage. Find this line:
        print("  python main.py              Process sample prescription")
  • Add this line directly below it:
        print("  python main.py --batch      Process all images in prescriptions/")
  • Save main.py.
Run the batch processor

Before you run this, think about what you expect to see. Each prescription has different handwriting. Do you think all files will succeed, or will some fail?

  • Run the batch processor by running this command:
python main.py --batch

You should see each prescription processed one by one, with raw OCR output and structured extraction for each file. At the end, the summary shows how many succeeded, how many failed, and the average confidence across all extracted fields.

What should I see?

The output starts with each prescription's individual results (the same output you saw when processing a single file). After all files are done, you see a summary like:

Batch complete: 3 succeeded, 0 failed. followed by Average confidence across all fields: 87.4% (your percentage will vary depending on the handwriting in your sample images).

After the batch summary, query_all() prints the full database table showing every prescription processed so far.

🙋‍♀️ Seeing 0 files processed?

Make sure your prescriptions/ folder contains image files with .png, .jpg, or .jpeg extensions. PDF files are not supported in this batch mode.

Check that you are running the command from your prescription-reader project folder (the folder that contains main.py and the prescriptions/ subfolder).

Help me debug why my batch processor finds 0 files.

💡 Why does confidence vary across prescriptions?

Every doctor's handwriting is different. Neat block letters score higher confidence than rushed cursive. Smudged ink, angled paper, and overlapping words all lower Textract's confidence scores.

The average confidence across your batch tells you how reliable the pipeline is for real-world prescriptions. In a production system, you would use this number to decide whether to trust the extraction or flag it for a human to review.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py with all your changes.

ⓧ I'd like to double check the full code

Your main.py is now over 30 lines in total. Scroll through and confirm these sections are present, in order:

  • Imports and client setup at the top.
  • init_table(), verify_connection(), read_file(), detect_text(), print_raw_ocr() functions from earlier steps.
  • extract_with_queries(), store_prescription(), query_all(), search_by_medication() functions from earlier steps.
  • process_single() from the previous step.
  • The new process_batch() function with file discovery, processing loop, and summary reporting.
  • main() with the new --batch elif branch above the --search branch.

Your pipeline now handles multiple prescriptions in one run, with resilience against individual failures and clear statistics showing how well Textract handles varying handwriting. Next up, you can extend this with confidence-based flagging in the Secret Mission.

Secret mission

Confidence-Based Flagging for Human Review

Your batch processor reports average confidence, but it treats every extraction the same regardless of quality. In a real clinical setting, a 60% confidence extraction of a medication name could mean a dangerous misread. Can you add a confidence threshold that splits results into 'verified' and 'needs review' categories?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project uses Amazon Textract (free tier: 1,000 pages/month for DetectDocumentText, 100 pages/month for Queries, for 3 months) and Amazon DynamoDB (free tier: 25 read/write capacity units permanently). There are no ongoing costs for the volume used in this project.

Resources you used:

  • Amazon Textract API calls (billed per page processed, within free tier for this project's volume).
  • Amazon DynamoDB table (prescriptions) storing extracted prescription records.
  • Project folder on your Desktop (contains main.py, prescriptions/ folder, and .venv virtual environment).

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment further with the script.

  • Amazon Textract has no persistent resources. It only charges per API call, so leaving the project as-is costs nothing.
  • Your DynamoDB table stays within the always-free tier (25 read/write capacity units). It costs nothing to keep.
  • Your local files remain on disk and your virtual environment stays ready to use.

Pause - I'll come back to this later

Shut down your virtual environment to free up your terminal. Your files and DynamoDB table remain for when you return.

  • Deactivate your virtual environment by running:
deactivate

When you come back, navigate to the project folder and reactivate the environment to pick up where you left off.

Delete - I don't want to use this again

Remove all project files from your machine and delete the DynamoDB table from AWS.

  • Delete the DynamoDB table by running this command:
aws dynamodb delete-table --table-name prescriptions
  • Deactivate your virtual environment, then delete the entire project folder.

macOS

deactivate
cd ~/Desktop
rm -rf prescription-reader

Windows

deactivate
cd %USERPROFILE%\Desktop
rmdir /s /q prescription-reader

Linux

deactivate
cd ~/Desktop
rm -rf prescription-reader
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see prescription-reader in the output.

Nice Work!

Nice Work!

Nice work! You just built a document intelligence pipeline that reads handwritten prescriptions using Amazon Textract, extracts structured medical data, and stores everything in a searchable database.

You've learned how to:

  • Built a Python script that sends prescription images to Textract's DetectDocumentText API and identifies handwriting versus printed text with per-word confidence scores.
  • Used Textract's Queries feature to extract structured medical fields (patient name, medication, dosage) by asking natural-language questions directly against documents.
  • Stored AI-extracted prescription data in Amazon DynamoDB and built scan and search capabilities across all processed records using filter expressions.
  • Secret Mission: Built a confidence-based flagging system that routes low-confidence extractions to a human review queue.

Ready to quiz yourself?