How we built an LLM-powered data classification system to scale data governance
Learn how Grow built an LLM-powered data classification system that classifies database columns in accordance with a classification matrix, allowing us to scale data governance.
Thoughts
·
4 min

This post is written by Nick Zelei, Staff Software Engineer.
During sessions with their therapists on Grow’s platform, clients often share highly sensitive and personal information, such as their diagnoses, trauma, or medication history. This information lives in different parts of our systems, whether in structured data fields, free-form session notes, or datasets derived from these data points. However, as we’ve scaled and added more people, features, and code, it’s become increasingly difficult to track, manage, and reason about where sensitive data lives, who should be able to access it, and what safeguards should apply when it's used. Getting this right is critical not just to protect client privacy, but also a key factor in ensuring clients trust the platform and continue to show up for care.
We’ve addressed this problem by building a custom LLM-powered data classification system that automatically reviews all data columns and classifies their sensitivity levels based on our data governance policies. This data can then be consumed by downstream systems, such as our GraphQL query resolvers, to automatically classify and protect user data. The system has been live in production since January of this year and has classified over 100,000+ columns in our database.
In this blog post, we’ll explain what the system does, how we built it, and where we’re thinking of taking it next.
What is the data classification system?
The data classification system runs daily across the entire Grow production database, identifying and classifying new data columns. These classifications are done at two levels: a primary classification, which identifies the overall risk level, and a sub-classification, which can be used to provide employees with granular data access (e.g., giving customer support access to the last 4 digits of a client’s credit card number to troubleshoot payment issues). We store the classifications as tags in our Snowflake data warehouse.
The primary logic of the classification is captured by the data classification matrix, which maps data types to their required safety levels. These levels dictate our policies for storing, accessing, and retaining each data type, ensuring the data remains secure and that the process meets regulatory requirements. Here’s a sample matrix, so you can get a sense of what it looks like (not our real matrix!):

A sample data classification matrix for a healthcare company (not our real matrix!)
Grow’s matrix has four main levels:
Critical: Any clinical or protected health information (PHI) data, which also includes personally identifiable information (PII) if it’s linked to clinical data. Examples: A client’s health history or diagnoses.
High: PII that isn't tied to health data. Examples: Provider PII, access tokens, credit card information.
Medium: Data for internal use only and not for public consumption. Example: Aggregate performance metrics of a feature.
Public: Non-sensitive data for public consumption. Example: Provider names.
To perform the initial classification, the system requires column metadata and a few sample column data so it understands the type of data it's working with. In addition to the classification, the system also outputs its reasoning. We’ve found this to be one of its most useful features because we can reference its reasoning when troubleshooting issues or seeking internal alignment if we disagree with the initial classification. There are a few validation layers in the system as well: a separate LLM-as-a-judge verifies the classification before it’s added as a tag (we’ll talk about this more in the next section), and a manual review process that lets us override the system’s classification if we disagree.
The base architecture of the data classification system
Initially, we considered using a third-party solution to build out the system, especially since Snowflake already had a PII classifier. While this approach would simplify the implementation, there were performance concerns. The classifier was primarily built using a named entity recognition model, making it suitable for classifying general-purpose PII (e.g., credit card numbers, phone numbers). However, it really struggled to handle nuanced logic in our data classification matrix. For instance, PHI is generally considered critical data, but if it’s de-identified and part of aggregated clinical health data, then it’s no longer considered critical. The classifier couldn’t handle this exception and kept classifying all health-related data (even something like “patient_dark_mode_preference”) as critical.
We also considered combining three sources (Snowflake’s PII classifier, an in-house model trained on a human-labeled dataset of database columns and their classifications, and a third-party LLM) and using their consensus as the final classification. However, calibrating all three models wasn’t straightforward. Snowflake’s classifier was too basic (as explained above), and the dataset we wanted to use to train the in-house model had inconsistent labels. Meanwhile, we saw promising results with just the third-party LLM. So, instead of combining sources, we decided to make a single third-party LLM the foundation of our system.
To find the best LLM for our use case, we tested several different options. Because the model would process real data for classification, we limited our search to providers willing to sign a Business Associate Agreement (BAA). This ensured that the LLM provider adequately protects user data and complies with key regulations, like the Health Insurance Portability and Accountability Act (HIPAA). In the end, for both the classifier and judge LLMs, we chose the same model: a larger model with reasoning capabilities, which improved accuracy and output quality. For the judge in particular, we saw that it led to less confusion and less wavering between verdicts, which was key to maintaining output quality.
Building out the data classification pipeline
With our base architecture figured out, we now had to build a production-grade system that could run these column classifications at scale. To do so, we use a producer-consumer workflow that identifies columns in our production database that need classification and classifies them (both the primary and sub-classification).
Here’s how it works. The producer flow scans all columns in Snowflake, identifies those that need classification, and ingests them into our work queue table. We track which columns need classification using a status column; columns with pending or missing classifications are added back to the queue. A consumer flow picks up a batch of columns and passes it to an LLM for classification, along with the metadata and some sample records. Once it finishes processing a column, the LLM returns the classification, a confidence interval (0 to 1) for that classification, and a rationale for its decision.
The confidence level field is particularly important because it helps us manage which columns might need to be reclassified. Here’s the logic:
If the model has high confidence (i.e., a confidence score of 0.8 or higher): Trust the system’s output and do not reclassify it.
If the confidence is medium (i.e., confidence score of 0.3-0.8): Pass it to the judge for further verification and enqueue it in a reclassification queue to retry classification later (particularly useful for new columns, where it can sometimes take a while to gather sufficient sample data).
If the confidence score is low (i.e., confidence score <0.3): Follow the same process as medium, and we also flag it for human review.
If the confidence score is unknown: Flag the column for manual human review.
As mentioned earlier, we use a separate LLM as a judge to evaluate the consumer’s response against the classification matrix and safeguard against LLM limitations such as hallucinations. Unlike the classifier LLM, the judge has access to a limited context (e.g., the LLM’s reasoning and the data classification matrix) and uses this information to double-check the classification. Similar to the primary classifier LLM, the judge must also output its reasoning and confidence, so we can double-check the judge’s work. But it has authority over the primary classifier; if the judge disagrees with the primary LLM’s classification, we go with the judge’s verdict. Note that we don’t run the sub-classification through a judge, since the primary classification has already been validated.
Once we have all these results, we pass them to the Snowflake data warehouse to be added as tags on the appropriate column.

An overview of how our data classification system works.
To prevent failures from overloading our system or the LLM provider, we implemented several mechanisms to control the rate of classification requests. For the producer, each new column is assigned a short cooldown period before it becomes eligible for classification, which staggers requests and gives the system time to collect representative sample data. For both the producer and consumer, we cap the number of new columns that can be added or classified at a given time to keep the volume of requests within manageable limits.
Evaluating system performance
Building the above system only took two weeks. The real rub was evaluating it to ensure it worked for our use case before we could roll it out. It was one of the more time-consuming parts of the project, but we knew that getting this right was critical to the system’s success.
We ended up running eight eval cycles until we were satisfied with the system performance. For each eval cycle, we ran a full classification of our system, dumped the output into a Snowflake table, and then exported it to a spreadsheet. Our team went through this list (focusing primarily on columns classified as critical and high-level) and looked for any obvious mistakes. We also leveraged AI to review the list and identify any patterns. Then, we looped in folks from engineering, security, and data engineering to catch any issues we missed and weigh in on any classifications we weren’t sure about. This whole process could take multiple weeks, as we sometimes had to manually review almost 2,000 columns in a single cycle!

Through these eval cycles, we noticed a consistent trend: the model kept unnecessarily classifying columns as sensitive. For instance, a provider's email is considered critical only if it’s associated with medical data; otherwise, it’s high (as all PII is classified as high by default). The classifier, however, thought it was always critical because the column had “provider” in the title.
To fix this issue, we made two main changes. First, we iterated on the data classification matrix to clarify how to handle edge cases, such as classifying non-PII data (e.g., created_at timestamps) when it’s linked to PII. Second, we tweaked the primary classifier’s prompt to ensure it understood these nuances, which required experimenting with different ways of structuring data input and reasoning steps. For instance, we added specific guidelines that system-generated columns and database identifiers are always moderate.
Over time, we saw fewer major pieces of feedback in our eval process. We decided the system was ready and rolled it out to production.
Impact & what’s next
In January 2026, we fully rolled out the data classification system, and since then, it has automatically classified all of our production columns. In the first few weeks of rolling out the system, we received feedback on the classifications and made some adjustments, but for the most part, the system has run independently.
With this system in place, we’re now focused on building new systems to propagate this data throughout our infrastructure and further improve data security. For instance, we’ve extended this system to our GraphQL query resolvers, which allows us to automatically enforce fine-grained authorization policies in our GraphQL schemas, reducing the risk of data exposure. The system also gives us a consistent, auditable framework for controlling access to sensitive data across our internal systems for all employees and customer support agents. For example, we’ve used it to enable data masking in our Snowflake warehouse and other internal applications (like Retool dashboards), allowing us to show or obscure data fields based on an employee’s permissions. This has two benefits: employees can access the data they need without accidentally exposing sensitive information, and requests for additional access follow an auditable escalation path (e.g., we can log their request, when they had access, what they saw, etc.).
However, this is just the beginning of the journey. We’re excited to explore upgrading our underlying model to further improve performance, which will require us to build an eval test suite to track performance across different model versions. We’re also interested in exploring new types of models (such as Jev and OpenAI’s Decisions API) to see if they can help us do these classifications more efficiently.
–
If you’re interested in building production-grade AI systems that help protect customer privacy, come work for us. Check out our open roles.



