You get the keys to a new org, log in, and land on a Setup menu that will not tell you what actually matters. Which objects carry the data. Which ones have three flows and a trigger fighting over the same record. Whether anyone has logged in since 2023.
You can click through Setup for an afternoon. You can ask the client and get “I think maybe someone set that up a few years ago?” Or you can point a Jupyter notebook at the org and read the answers off the metadata tables.
Why a notebook rather than a script
A script gives you the answer it was written to give. A notebook lets you run a query, look at what came back, and write the next query based on that, all in one session with the connection still open and the DataFrames still in memory. Discovery is mostly follow-up questions, so that matters.
The second reason is what you’re left holding afterward. A notebook is markdown headings, the SOQL, the result tables, and the charts in one file. I’ve handed the exported notebook to a client as the discovery artifact more than once; it beats a summary deck because they can see the query that produced each number.
Mostly, though, I keep the same notebook and reuse it. New engagement, plug in the credentials, Run All, and in a few minutes I know the shape of the org. It doesn’t replace discovery conversations. It tells me which ones to have.
The setup
The tooling is straightforward. I use simple_salesforce as a Python client for the Salesforce REST API, pandas for wrangling data into DataFrames, and matplotlib (with seaborn for aesthetics because I have some self-respect) for visualization.
import pandas as pd
import matplotlib.pyplot as plt
import seaborn
from simple_salesforce import Salesforce
seaborn.set()
The notebook is designed to run either locally or in Google Colab, which is nice when you’re pairing with someone who doesn’t have a local Python environment set up.
Credentials
This is the part where I get on a small soapbox. Never hard-code your Salesforce credentials. Not in the notebook, not in a config file you commit to Git, not anywhere that isn’t explicitly designed for secrets management.
When running in Google Colab, I pull credentials from Colab’s userdata secrets store:
from google.colab import userdata
sf = Salesforce(
username=userdata.get('SF_USERNAME'),
password=userdata.get('SF_PASSWORD'),
security_token=userdata.get('SF_SECURITY_TOKEN'),
consumer_key=userdata.get('SF_CLIENT_ID'),
consumer_secret=userdata.get('SF_CLIENT_SECRET'),
domain=userdata.get('SF_DOMAIN')
)
When running locally, I load from an environment file that is .gitignored into oblivion:
import json
filename = f'./envs/{env_name}.json'
with open(filename, 'r') as f:
creds = json.load(f)
sf = Salesforce(
username=creds['username'],
password=creds['password'],
security_token=creds['security_token'],
consumer_key=creds['client_id'],
consumer_secret=creds['client_secret'],
domain=creds['host']
)
The environment file itself is just a simple JSON blob with your connected app credentials and login info. The key point is that it stays local. It never touches version control. If you’re using dotenv or OS-level environment variables instead, even better. The principle is the same: secrets stay secret.
(Stepping off soapbox now.)
What the notebook checks
Here’s the rough structure of my standard discovery notebook. Think of it as a checklist of the things I want to know about any org before I start making promises.
1. Daily API request usage
The very first thing the notebook does after authenticating is check the org’s daily API request usage. This matters because the notebook itself will consume API calls, and if you’re connecting to a production org that’s already running hot, you want to know before you fire off a few hundred SOQL queries.
limits = sf.limits()
api_usage = limits['DailyApiRequests']
consumed = api_usage['Max'] - api_usage['Remaining']
pct = round(consumed / api_usage['Max'], 2) * 100
if pct > 80:
print(f'WARNING: {pct}% consumed. Halting.')
raise StopExecution
else:
print(f'{pct}% of daily API requests consumed. Proceeding.')
I built in a StopExecution exception that halts the notebook if API consumption is above 80%. It’s a small thing, but it’s saved me from an awkward conversation at least once.
2. Apex class count and size
Next up: getting a sense of the codebase. I query all active Apex classes (excluding managed packages and test classes) and calculate each one’s size as a percentage of Salesforce’s character limit. This gives me a quick feel for how much custom code exists and where the big files live.
# Find test classes via SOSL
test_classes = sf.search(
"FIND {@isTest} IN ALL FIELDS RETURNING ApexClass(Id, Name)"
)
# Query all active, unpackaged Apex classes
all_classes = sf.query(
"SELECT Id, Name, Body, LengthWithoutComments "
"FROM ApexClass "
"WHERE Status = 'Active' AND NamespacePrefix = null"
)
From there, it’s just a matter of calculating byte sizes and sorting. A class sitting at 3% of the org’s total Apex limit is worth investigating. A handful of large utility classes from an open-source logging library? Probably fine. A 50KB class called DataFactory with no clear naming convention? That’s where you start asking questions.
3. Automation inventory: flows, Process Builders, triggers
This section is the one I find most valuable in early discovery. Salesforce orgs have a tendency to accumulate automation like a junk drawer accumulates takeout menus. Process Builders, record-triggered flows, Apex triggers… they all pile up. If you don’t know what fires on save and in what order, your first estimate on that object will be wrong.
Process Builders:
process_builders = sf.query(
"SELECT ApiName, Label, InstalledPackageName, VersionNumber "
"FROM FlowDefinitionView "
"WHERE IsActive = TRUE AND ProcessType = 'Workflow'"
)
Just seeing the count here tells you something. A handful of Process Builders on a mature org? Normal. Fourteen of them, some from managed packages, some clearly hand-rolled years ago? That’s a migration conversation waiting to happen (since Salesforce is sunsetting Process Builder in favor of Flows).
Record-Triggered Flows:
flows = sf.query(
"SELECT ApiName, TriggerType, Label, TriggerObjectOrEventLabel, "
"RecordTriggerType, TriggerOrder "
"FROM FlowDefinitionView "
"WHERE IsActive = TRUE "
"AND (TriggerType='RecordAfterSave' OR TriggerType='RecordBeforeSave')"
)
I chart these by object label to see which objects are the busiest. If an object has multiple before-save flows, after-save flows, and Apex triggers, that’s a land mine. You need to understand the order of execution and whether they’re stepping on each other.
Apex Triggers:
triggers = sf.query(
"SELECT Name, TableEnumOrId, NamespacePrefix, Status, "
"UsageAfterInsert, UsageAfterUpdate, UsageBeforeInsert, UsageBeforeUpdate "
"FROM ApexTrigger WHERE Status = 'Active'"
)
I split these into managed package triggers versus unpackaged triggers. Managed package triggers (like the ones from CPQ, which can have dozens) are largely out of your control but important to be aware of. Unpackaged triggers are the ones you’ll be maintaining, and visualizing them by object and trigger event gives you an instant sense of complexity.
4. Record counts by object
I query every customizable SObject in the org and get record counts for each. This takes a minute (it’s a lot of queries), but the result is a clear picture of which objects are heavily used and which are ghost towns.
entities = sf.query_all(
"SELECT QualifiedApiName "
"FROM EntityDefinition "
"WHERE IsCustomSetting = FALSE AND IsCustomizable = TRUE"
)
# Then, for each object:
count = sf.query(f"SELECT COUNT() FROM {object_api_name}")
Objects with zero records are interesting. Did someone build this out and never use it? Objects with millions of records are interesting too. Is storage going to be a concern? I also plot record growth over time for the top objects by querying CreatedDate on recent records. A sudden spike in record creation usually has a story behind it.
5. License utilization and last login
Finally, I look at licensing and user activity. License utilization tells you whether the org is right-sized or if you’re paying for seats nobody’s sitting in. User activity (specifically LastLoginDate) tells you who’s engaged and who’s not.
licenses = sf.query(
"SELECT Name, TotalLicenses, UsedLicenses, Status "
"FROM UserLicense"
)
users = sf.query(
"SELECT Name, IsActive, Profile.Name, UserRole.Name, "
"LastLoginDate, Profile.UserLicense.Name "
"FROM User "
"WHERE IsActive = TRUE AND UserType != 'AutomatedProcess'"
)
A user who hasn’t logged in for 90+ days is worth flagging. A pile of unused Salesforce Platform licenses? That’s a cost optimization conversation.
What it doesn’t do
The notebook counts things. It cannot tell you that the overlapping Opportunity automation exists because two teams shipped in the same quarter and never spoke, or that the object with zero records is the one the client is betting next year on. Those answers come from admins, developers, and end users.
What it changes is the first meeting. Instead of “walk me through your org,” you open with “you have fourteen active Process Builders, and a before-save flow on Opportunity that looks like it overlaps with an Apex trigger; tell me about that.” You get a better answer, and you spend the hour on the parts a query can’t reach.