Banner & Argos Feynman Wiki

Banner & Argos, explained simply

Plain-English, diagram-rich explanations of how Banner and Argos actually work.

press / to search · esc to clear · to navigate results
55 articles

AWhat is it, really

PIDM as the universal join column PIDM is the universal join column every person-bearing table carries the same person key SPRIDEN identity + name history spriden_pidm SFRSTCR course registration sfrstcr_pidm SGBSTDN general student sgbstdn_pidm GOBEACC user account gobeacc_pidm PIDM integer - one per person one column - four tables - one shared person
Track A · What is it, really
PIDM — The Number Behind Every Person

Every person in Banner has two names: the one you see, and the one the database uses. The one the database uses is a number you were never meant to know about. It is called PIDM, and it is the single most important column in every SQL query you will ever write against Banner.

bannerpidmspriden
Banner table-name prefix anatomy Banner table names carry three positional clues letter 1 = system area, letters 2-3 = application, rest = object S PR IDEN SPRIDEN S = Student | PR = Person record | IDEN = identification S FR STCR SFRSTCR S = Student | FR = Course registration | STCR = student course N BB POSN NBBPOSN N = Position Control | BB = Budget/base | POSN = position F TV ORGN FTVORGN F = Finance | TV = validation table | ORGN = organization system area S Student F Finance N Position Control G General read Banner names left to right: area, application, object
Track A · What is it, really
Reading a Banner Table Name — The Seven-Letter Code

You see `SPRIDEN`, `SFRSTCR`, `NBBPOSN`, `GOBEACC` every day. They look like random seven-letter strings. They are not. Each one is a road map — and once you can read it, you can guess what domain any Banner table belongs to without opening a data dictionary.

bannertable-namingconvention
Validation table join from SGBSTDN to STVMAJR Validation tables translate compact codes SGBSTDN STVMAJR pidm 38201 term_eff '202610' majr_code_1 'BIO' levl_code 'UG' stvmajr_code 'BIO' desc 'Biology' valid_a_ind 'Y' JOIN ON code LEFT JOIN stvmajr ON stvmajr_code = sgbstdn_majr_code_1 the join translates the code to its description
Track A · What is it, really
STV* and GTV* — Banner's Code Dictionaries

Banner stores codes — not names, not descriptions, not the words a human reads. `SGBSTDN_MAJR_CODE_1 = 'BIO'`, `SFRSTCR_RSTS_CODE = 'RE'`, `STVTERM_CODE = '202610'`. The code is compact, efficient, and completely opaque. The translation is in a second set of tables — the STV* and GTV* dictionaries — and if you don't know they exist, you're reading a foreign language without the dictionary.

bannervalidation-tablesstvterm
Banner schemas, cross-schema joins, and synonyms Tables live in schemas; joins can cross them the schema prefix names the owner, not a different kind of table SATURN SPRIDEN SGBSTDN SFRSTCR SCBCRSE GENERAL GOBEACC GUBALOG GOREMAL PAYROLL PHRHIST PEBEMPL FIMSMGR FOBAPPD FTVORGN TAISMGR TBRACCD TBBDETC JOIN PIDM PUBLIC SYNONYM gobeacc -> general.gobeacc synonyms let you write SELECT * FROM gobeacc without the schema prefix
Track A · What is it, really
Schemas — Which Drawer the Table Lives In

You type `SELECT * FROM gobeacc` in your SQL editor. Oracle returns `ORA-00942: table or view does not exist`. The table definitely exists — you saw it in BSS. The problem is not whether it exists. The problem is which drawer it lives in.

banneroracleschema
Effective-dated rows as versions in time Same person - same record - four versions in time pidm = 47281 eff_date = 2019-08-15 Adjunct Instructor pidm = 47281 eff_date = 2021-01-04 Full-Time Faculty pidm = 47281 eff_date = 2023-08-22 Senior Faculty pidm = 47281 eff_date = 2025-08-18 Department Chair CURRENT MAX(eff_date) <= TODAY effective dating appends history instead of overwriting it
Track A · What is it, really
Effective Dating — Why Banner Never Forgets

A student changes majors. Banner does not cross out the old one and write the new one on top. It lays a new row on top of the old one and dates it. If your query does not specify which layer you want, Banner hands you all of them — and your report is silently wrong.

bannereffective-datingsgbstdn
Argos report anatomy Argos report, x-rayed into three layers parameter values flow down into the SQL rows flow up into the layout PARAMETERS v :main_DD_term_code dropdown | :main_EB_subj_code edit-box [] :main_DA_as_of date-picker REPORT CRN Subject Course# Student ID Student Name PDF / CSV / Excel export DATABLOCK SELECT r.sfrstcr_crn, s.spriden_id, ... FROM sfrstcr r JOIN spriden s ON s.spriden_pidm = r.sfrstcr_pidm WHERE r.sfrstcr_term_code = :main_DD_term_code;
Track A · What is it, really
Argos, X-Rayed — The DataBlock, the Report, the Parameters

Everyone calls it 'a report.' But what you see on screen — the columns, the headers, the dropdowns at the top — is only one of three components layered behind the glass. X-ray the thing, and you see a structure that nobody taught you explicitly: the DataBlock, the Report, and the Parameters. Three subsystems, one device, each invisible to the end user.

argosdatablockreport
Anatomy of Banner term code 202610 Anatomy of term code '202610' the code is compact, but STVTERM is the authority ' 2026 10 ' YYYY academic year anchor TT - season 10=Fall at most installations; verify STVTERM looks up in STVTERM STVTERM row for 202610 STVTERM_CODE '202610' STVTERM_DESC 'Fall 2026' STVTERM_START_DATE 2026-08-24 STVTERM_END_DATE 2026-12-15 STVTERM_ACYR_CODE '202627' * STVTERM_FA_PROC_YR '2627' * * ACYR may use its own format FA_PROC_YR is federal FA year
Track A · What is it, really
TERM Codes — The Academic Timestamp Banner Uses Everywhere

You see `'202610'` in every WHERE clause you write. You have used `MAX(sgbstdn_term_code_eff)` a hundred times. But nobody ever told you why the format was chosen, why it sorts correctly without casting, or what `STVTERM` actually holds. The term code is not a magic number. It is ISO 8601 adapted to academic time — and the format IS the feature.

bannerterm-codestvterm

BThe canonical joins

Join PIDM-bearing sources through SPRIDEN SFRSTCR pidm term_code crn PHRHIST pidm year gross GOBEACC pidm userid status SPRIDEN pidm id last_name first_name change_ind entity_ind ON source_pidm = spriden_pidm AND change_ind IS NULL AND entity_ind = 'P' ON source_pidm = spriden_pidm AND change_ind IS NULL AND entity_ind = 'P' ON source_pidm = spriden_pidm AND change_ind IS NULL AND entity_ind = 'P' ON pidm = pidm AND change_ind IS NULL AND entity_ind = 'P' - three conditions, every time.
Track B · The canonical joins
Joining by PIDM — SPRIDEN and the Universal Key

Every report that displays a person's name uses the same three-line SQL incantation. It looks like boilerplate. It is not. Each condition earns its place — and if you move any of them to the wrong clause, you change what the word LEFT means.

bannerpidmspriden
Compound join: term code plus CRN SFRSTCR pidm term_code crn credit_hr SSBSECT term_code crn subj_code crse_numb ON sect.ssbsect_term_code = r.sfrstcr_term_code AND sect.ssbsect_crn = r.sfrstcr_crn CRN alone is NOT global - it is reused across terms. Both conditions are required.
Track B · The canonical joins
TERM_CODE + CRN — The Registration Compound Key

You write `JOIN ssbsect ON ssbsect_crn = sfrstcr_crn`. The query runs. It returns rows — five times more than expected. The CRN looked global. It is not. CRN is unique only WITHIN a term, and you just joined across every term that ever reused it.

bannerterm-codecrn
The MAX effective-date subquery pattern The MAX(eff_date) pattern has two passes inner: one row per PIDM with the latest date SELECT pidm, MAX(eff_date) FROM nbrjobs GROUP BY pidm pidm max_eff_date 47281 2025-08-18 51002 2024-03-11 62144 2025-08-18 JOIN ON pidm AND eff_date outer: the full current NBRJOBS row pidm eff_date job_title salary_grade 47281 2025-08-18 OK Department Chair F12 51002 2024-03-11 OK Payroll Analyst S08 62144 2025-08-18 OK Financial Aid Lead S10 two passes - same table - one correct row each
Track B · The canonical joins
The MAX() Subquery — Getting the Row That's Current

You will write this pattern a hundred times in your Banner career. Four lines of SQL that look like noise the first time you see them, and like the only thing holding the report together every time after. It is the most important SQL idiom in the entire Banner codebase, and once you can read it in your sleep, every effective-dated table in the ERP opens up.

bannersql-patterneffective-dating
Two SPRIDEN aliases from one intermediate table SPRIDEN s pidm last_name first_name SGRADVR sgradvr_pidm sgradvr_advr_pidm sgradvr_term_code_eff sgradvr_prim_ind SPRIDEN ai pidm last_name first_name alias = student alias = advisor identity Two SPRIDEN joins, two aliases, same 3-condition ON clause on each.
Track B · The canonical joins
The Double SPRIDEN — Naming Two People in One Query

You need a student's name and their advisor's name on the same row. Both live in SPRIDEN. You join SPRIDEN once and try to get both — and Oracle returns the same name twice. The fix is not a different table. The fix is a second alias.

bannerspridendouble-join
Security joins route through GOBEACC GURACLS userid class_code activity_date GOBEACC userid pidm status_ind SPRIDEN pidm id last_name first_name change_ind entity_ind ON userid ON pidm + 3-cond + AND gobeacc_status_ind = 'A' (active accounts only) GURACLS is keyed by userid, not PIDM - route through GOBEACC to reach SPRIDEN.
Track B · The canonical joins
The Security Audit Join — GURACLS Done Right

An auditor asks: 'Show me everyone who has the STUDENT_RECORDS access class.' The answer lives in a single table — GURACLS. But GURACLS doesn't know anyone's name. It only knows user IDs. To answer the auditor's question, you need a three-table chain, and if you miss the active-account filter, the report includes people who left in 2018.

bannerguraclsgobeacc
Catalog entry versus section offerings SCBCRSE - the catalog entry (the WORK) SSBSECT - sections (the BORROWABLE COPIES) SCBCRSE row subj_code = 'ENGL' crse_numb = '201' eff_term = '202110' title = 'Introduction to British Literature' credit_hr = 3 term=202110 crn=12345 subj='ENGL' crse='201' seq=001 instructor=Smith term=202210 crn=34567 subj='ENGL' crse='201' seq=001 instructor=Jones term=202310 crn=56789 subj='ENGL' crse='201' seq=001 instructor=Chen One catalog entry, many sections; students enroll in sections, not in the catalog.
Track B · The canonical joins
Catalog vs Section — SCBCRSE and SSBSECT

SCBCRSE has a column called `eff_term`. SSBSECT has a `term_code`. They look related — so people join them. And when they do, three catalog versions of the same course silently multiply the result by three, and a 2020 transcript retroactively shows the 2024 course title. The join needs a bound, not just an equality.

bannerscbcrsessbsect

CFrom generic SQL to Banner

Oracle equivalents for common SQL idioms Generic SQL / Other Dialect Oracle Equivalent NOW() SYSDATE ISNULL(a,b) NVL(a,b) + (string concat) || TOP 10 WHERE ROWNUM <= 10 GETDATE() SYSDATE LEN(s) LENGTH(s) CONVERT(t, expr) TO_CHAR / TO_NUMBER / TO_DATE SELECT 1 (no FROM) SELECT 1 FROM dual Most Oracle migrations start with these mechanical substitutions.
Track C · From generic SQL to Banner
Banner Runs on Oracle — The Dialect You Will Meet

SQL is a standard. Oracle's version of it has its own vocabulary — small differences scattered through every query, none hard, none avoidable. You can't read Banner SQL for ten minutes without meeting `SYSDATE`, `NVL`, `DUAL`, `||`, `ROWNUM`, and `DECODE`. Learn them once, and the dialect becomes the language.

oraclebannersql-dialect
SQL Server to Oracle translation table Category SQL Server Oracle current date/time GETDATE() SYSDATE add months DATEADD(month, n, d) ADD_MONTHS(d, n) year part DATEPART(year, d) EXTRACT(YEAR FROM d) null replacement ISNULL(a, b) NVL(a, b) concat a + b a || b length LEN(s) LENGTH(s) find substring CHARINDEX(needle, hay) INSTR(hay, needle) top N rows SELECT TOP 10 * WHERE ROWNUM <= 10 cast to string CONVERT(varchar, d, 23) TO_CHAR(d, 'YYYY-MM-DD') quoted name [Square Brackets] "Double Quotes" Most are mechanical - see C3 for semantic-difference gotchas.
Track C · From generic SQL to Banner
From SQL Server to Oracle — Translating Your Instincts

You know how to write SQL. You've written hundreds of queries on SQL Server. Then you open a Banner DataBlock and see `SYSDATE`, `NVL`, `ROWNUM`, `DUAL`, `||` — and every instinct you have about what to type is a half-second wrong. The skill carries. The syntax doesn't. Here is the translation.

oraclesql-serverdialect-translation
Oracle to PostgreSQL migration table Oracle PostgreSQL Type SYSDATE CURRENT_TIMESTAMP mechanical NVL(a, b) COALESCE(a, b) mechanical ROWNUM LIMIT N mechanical TO_CHAR(d, fmt) TO_CHAR(d, fmt) mechanical DUAL (no FROM) mechanical (+) outer join ANSI JOIN semantic - rewrite '' = NULL (true) '' = NULL (false) semantic - audit MERGE INSERT ... ON CONFLICT semantic - rewrite Semantic rows need careful rewriting - they are not 1:1 substitutions. Audit every IS NULL and = '' before the SaaS migration.
Track C · From generic SQL to Banner
From Oracle to PostgreSQL — the Banner SaaS Migration

Ellucian's cloud Banner targets PostgreSQL, not Oracle. Every Argos DataBlock you write today in Oracle SQL will eventually run against a PostgreSQL database. Some of the SQL translates mechanically. Some doesn't. And one difference — `'' = NULL` — will silently change what rows your query returns without raising an error.

oraclepostgresqldialect-translation
Legacy (+) to ANSI JOIN translation Legacy (+) -> ANSI JOIN translation LEFT JOIN WHERE a.x = b.x(+) = LEFT JOIN b ON b.x = a.x RIGHT JOIN WHERE a.x(+) = b.x = RIGHT JOIN b ON a.x = b.x FULL OUTER JOIN WHERE a.x(+) = b.x(+) = FULL OUTER JOIN b ON a.x = b.x Filter conditions on the OUTER side must move INTO the ON clause. See E1 for the WHERE-vs-ON trap.
Track C · From generic SQL to Banner
From (+) to ANSI — Retiring Oracle's Old Outer Join

You open an older Banner SR report and see `WHERE a.x = b.x(+)`. It looks like a typo. It is not. It is Oracle's pre-ANSI outer join syntax — the stick-shift of the SQL world. It still runs, but PostgreSQL won't accept it, and the modern world has moved on. Here is the translation.

oracleansi-joinlegacy

DThe craft of Argos

Argos parameters as three nested scopes Argos parameters - three nested scopes :dbn_term - set by user at runtime :lcl_dept - per-Report scope :main_year - DataBlock-level, set once WHERE term_code = :dbn_term AND dept_code = :lcl_dept AND year = :main_year :dbn wraps :lcl wraps :main, matching each parameter lifetime
Track D · The craft of Argos
Argos Parameters — `:main_`, `:lcl_`, `:dbn_`

Every Argos report is a building full of rooms, and every parameter is a microphone. The question is never 'does this parameter exist?' It is always 'can this room hear it?' The three prefixes — `:main_`, `:lcl_`, `:dbn_` — are the three answers to that question.

argosparametersscope
Argos string substitution before Oracle sees SQL Argos substitution - template plus value becomes SQL the colon token is replaced before the statement reaches Oracle TEMPLATE WHERE clause in the DataBlock WHERE r.sfrstcr_term_code = :main_DD_term_code PARAMETER user input '202610' ORACLE RECEIVES the concrete SQL text WHERE r.sfrstcr_term_code = '202610' + = String substitution, not bind variable. Oracle sees '202610', never sees :main_DD_term_code.
Track D · The craft of Argos
How Argos Assembles Your Query — Filters on the WHERE

You type `:main_DD_term_code` in your DataBlock SQL, the user picks 'Fall 2026' from a dropdown, and Oracle runs the query. What happens between the click and the execution is not parameter binding — it is string substitution, like a mail merge. The distinction explains every performance surprise, every silent breakage, and every 'it worked yesterday' your Argos users have ever reported.

argosparameterssubstitution
Optional multi-value Argos pattern card OPTIONAL MULTI-VALUE (checkbox) When the user may check zero or more values: omit the entire predicate when no boxes are checked. {{!IF :main_MC_ecls != ''}} AND pe.pebempl_ecls_code IN (:main_MC_ecls) {{!ENDIF}} WARNING: Naive form WHERE x IN (:multi) OR :multi IS NULL looks plausible, but ('A','B') IS NULL is FALSE; empty selection produces an IN () syntax error. safe recipe: include the IN predicate only when values exist
Track D · The craft of Argos
Seven Patterns Every Argos Report Needs

You have written the same WHERE clause a hundred times. Required filter, optional filter, multi-checkbox, date range, partial-text search, toggle, cascading dropdown. You debug the NULL edge case and the empty-selection syntax error from scratch every time. You don't need to. There are exactly seven patterns. Learn them once, copy them forever.

argosparameterswhere-clause
UNION ALL DataBlock layout anatomy One DataBlock, three layouts, three reports each SELECT branch stamps a layout value before UNION ALL UNION ALL UNION ALL SELECT 'SUMMARY' AS layout, ... FROM shared_source SELECT 'DETAIL' AS layout, ... FROM shared_source SELECT 'EXCEPTION' AS layout, ... FROM shared_source one DataBlock output Report A WHERE layout = 'SUMMARY' Report B WHERE layout = 'DETAIL' Report C WHERE layout = 'EXCEPTION' branch literal color = consumer report border color
Track D · The craft of Argos
Shared DataBlocks — One SQL, Many Reports

You have two reports that need the same underlying data — a summary and a detail view, both backed by the same financial aid transactions. You could write two DataBlocks. Two SQL bodies. Two sets of filters. Two copies of business logic that will drift apart the first time someone updates one and forgets the other. Or you could write one DataBlock with a discriminator column and let the consumer reports filter their slices. That is the shared-DataBlock pattern, and it is how Waubonsee's FAID1084 and FAID1006 work.

argosdatablockunion-all

EWhere intuition fails

How a LEFT JOIN keeps every left-hand row students left table - kept no matter what Student 1 Student 2 Student 3 sfrstcr right table - course registration Student 1 CRN 10421 Student 2 CRN 10580 Student 3 - no row LEFT JOIN result of the LEFT JOIN all three students survive STUDENT REGISTRATION Student 1 CRN 10421 Student 2 CRN 10580 Student 3 NULL
Track E · Where intuition fails
The Phantom INNER JOIN — When a WHERE Breaks Your LEFT JOIN

A report told to list every student lists only some — and the LEFT JOIN that was supposed to keep them is spelled out, correct, and innocent.

joinsleft-joinwhere-clause
SPRIDEN change indicators and the current name One PIDM - three name rows - only one is current SPRIDEN PIDM LAST_NAME FIRST_NAME CHANGE_IND 38201 Garcia Maria 'N' 38201 Lopez-Garcia Maria 'N' 38201 Lopez Maria NULL CURRENT ! without WHERE change_ind IS NULL -> 3 rows for one person OK with WHERE spriden_change_ind IS NULL -> 1 row, the current name
Track E · Where intuition fails
SPRIDEN Without CHANGE_IND — The Duplicate-Name Trap

You join to SPRIDEN, run the query, and scan the output. The names look right. The row count is wrong. You have just shipped a report with phantom duplicates — and the error is invisible because every column looks correct except the number at the bottom of the page.

bannerspridenchange-ind
PHRHIST disposition stack One employee, one pay period, four dispositions same gross amount in every row; only disp='P' is posted pidm=38201 disp='P' gross=$2,150.00 posted in GL pidm=38201 disp='A' gross=$2,150.00 approved pidm=38201 disp='C' gross=$2,150.00 calculated pidm=38201 disp='L' gross=$2,150.00 loaded WHERE phrhist_disp = 'P' Without the filter, SUM(gross) = $8,600 - 4x the real $2,150.
Track E · Where intuition fails
PHRHIST Without DISP — In-Progress vs Posted Payroll

You sum `PHRHIST_GROSS` for the fiscal year and the number looks right. It matches what you remember from the last payroll run. It is wrong. You have included rows from the payroll that is still being calculated — rows that look identical to posted rows in every column except one. The bank calls them 'pending.' Banner calls the column `PHRHIST_DISP`.

bannerphrhistdisposition
LISTAGG overflow anatomy LISTAGG roles against the 4000-byte SQL limit roles concatenate left-to-right until the string no longer fits 0 1000 2000 3000 4000 VARCHAR2 SQL limit (15 more) overflow roles Without ON OVERFLOW: silent truncation pre-12.2, ORA-01489 post-12.2.
Track E · Where intuition fails
LISTAGG Overflow — The List That Silently Truncates

You run a security report listing every role per user. The output looks fine — every user has a role list, every list looks plausible. But the user with 80 roles has only 47 in your output. The rest were truncated. No error fired. No warning appeared. You have shipped a report with missing data, and the only way to discover it is to count the commas by hand.

bannerlistaggoracle
Soft-delete flags in Banner tables Soft-delete rows stay in the table live rows are coral; rows to exclude are amber and struck out SFRSTCR registrations crn=12345 rsts_code='RE' registered crn=12345 rsts_code='DD' dropped, no grade AND rsts_code NOT IN ('DD','DW') SGBSTDN student status pidm=38201 stst_code='AS' active student pidm=47828 stst_code='WD' withdrawn AND stst_code NOT IN ('WD','LA') GUBALOG audit log audit_action='I' role added insert audit_action='D' role removed delete AND audit_action <> 'D'
Track E · Where intuition fails
Soft Deletes — The Rows That Aren't Really Gone

You withdraw a student in Banner. The row in SGBSTDN does not disappear — it gets a status code. You drop a registration. SFRSTCR keeps the row with a drop flag. You delete a security role. The audit log keeps an entry with AUDIT_ACTION = 'D'. Banner does not hard-delete. The rows stay in the table forever. Every report that does not filter them out is silently counting ghosts.

bannersoft-deletesfrstcr
Effective-date trap: target date changes the row Same SGBSTDN stack - two target dates - two majors the MAX row must be bounded to the report period PIDM 38201 PIDM 38201 term_eff='202410' major='Health Sciences' PIDM 38201 term_eff='202210' major='Nursing' PIDM 38201 term_eff='202010' major='Biology' target='today' MAX returns top row target='202210' (Fall 2022) MAX bounded Same student. Same stack. Two query bounds resolve to two different majors. Choose the bound to match the report's PERIOD, not the run date.
Track E · Where intuition fails
The Effective-Date Trap — Joining to Yesterday's Row

You run a report: 'Fall 2022 enrollment by current major.' The row count is right. The CRNs match. Every student has exactly one major. What nobody told you is that the major is from today — not from Fall 2022. You used the unbounded MAX-effective subquery from B3, and it silently tagged every historical registration with present-tense labels. The report is a history book whose author walked into the archive and swapped all the old placards for new ones.

bannereffective-datingsgbstdn
Positive filter trap: reversal rows must net Original + reversal are one payroll event filtering to gross > 0 drops the row that cancels it original pidm=38201 pay_event=PR12 gross=+$2,150.00 entered=2026-03-15 reversal pidm=38201 pay_event=PR12 gross=-$2,150.00 entered=2026-03-16 pairing key: pidm + pay_event WRONG WHERE gross > 0 -> SUM = $2,150.00 drops the reversal - inflates by $2,150 RIGHT (no filter) -> SUM = $0.00 positives + negatives net correctly
Track E · Where intuition fails
The `> 0` Trap — The Filter That Drops Reversals

You add `AND phrhist_gross > 0` to your payroll report. The intent is defensive: exclude zero rows, count only real amounts. The effect is the opposite of defensive. You have silently dropped every payroll reversal — every void, every adjustment, every back-out. Your 'total gross earnings' now includes money that was keyed by mistake and reversed the next day. The filter that was supposed to protect the report broke it.

bannerphrhisttbraccd

FFrom Banner to a warehouse

Top Banner objects by Argos report count Top Banner objects by Argos report count from argos_catalog.json - 191 DataBlocks scanned spriden 203 pwvempl 111 sfrstcr 74 ftvorgn 69 pebempl 67 nbbposn 55 spbpers 54 tbraccd 47 govsdav 44 spraddr 34 longer bar = more reports touch this table
Track F · From Banner to a warehouse
What Waubonsee Actually Reports Today — and Where the Warehouse Should Land First

Before you draw your first star, look at what the campus already prints every week. The Argos folder will tell you which warehouse to build first — and the answer is not the one you expected.

argosevidencewarehouse-strategy
OLTP vs OLAP comparison OLTP vs OLAP — two databases, two optimizations every design choice that helps one hurts the other OLTP — Banner OLAP — Warehouse Purpose Run the college — register, post, charge, print Understand the college — trend, compare, aggregate, forecast Workload Thousands of small writes per second Dozens of large reads per hour Normal form 3NF — no redundancy, write once Denormalized — redundancy is fine, joins are cheap Indexes Narrow, few per table — fast single-row lookup Wide covering indexes — fast full-scan + GROUP BY Locks Row-level, held for milliseconds No write locks — the warehouse is read-only Data age Real-time — this second Recent — last night's snapshot two databases, two purposes — the warehouse does not replace Banner; it complements it
Track F · From Banner to a warehouse
Why a Warehouse? — OLTP, OLAP, and the Cost of Asking Banner the Wrong Question

Banner registers a student in milliseconds — that is its job. Ask it how enrollment shifted over the last five years, and the same engine will contend for the very rows the registrar is touching right now. One database cannot be optimal for both tasks.

warehouseoltpolap
One fact row with its dimension context One fact row — every column is a key or a measure the fact is the measurement; the dimensions are the context FACT fct_position_ budget budgeted_amt actual_amt dim_date full_date fiscal_year acad_term is_holiday date_key dim_employee empl_id first_name last_name dept employee_key dim_position posn_code posn_title suffix status position_key dim_organization org_code org_name org_level org_key dim_fund fund_code fund_name category fund_key every column in the fact is either a foreign key to a dimension or a measure
Track F · From Banner to a warehouse
Facts, Dimensions, Measures — The Multidimensional View

Every report you have ever written follows the same hidden grammar: a number, sliced by context. You have been thinking in facts and dimensions your whole career. You just never called them that.

warehousekimballfacts
The star schema shape position_key employee_key org_key fund_key date_key dim_position the slot dim_employee who fills it dim_org where it reports dim_fund who pays dim_date which month fct_position_budget the central fact five dimensions, one fact, one hop each - that is the star
Track F · From Banner to a warehouse
The Star Schema — One Fact, Many Dimensions, and the Grain

A star schema is not a diagramming convention. It is a mechanical guarantee: every dimension is exactly one JOIN away from the fact. No exceptions, no shortcuts, no climbing branches.

warehousekimballstar-schema
Three slowly changing dimension choices One source change - three warehouse choices position 100123 is retitled from Director of IT to Director of Digital Transformation TYPE 1 before 100123 Director of IT history lost after 100123 Director of Digital Transformation TYPE 2 key=1042 100123 Director of IT end=2024-08-31 current=F retire + insert key=1087 100123 Director of Digital Transf. start=2024-09-01 current=T TYPE 3 position 100123 title Director of Digital Transf. previous Director of IT only one step back August report, rerun in October, silently shows new title. August report, rerun in October, still shows old title. Knows the prior title. Forgets the one before that.
Track F · From Banner to a warehouse
Slowly Changing Dimensions — Keeping History When Attributes Change

A dimension says what something *is*. But things change. If you overwrite the old value, you rewrite history. If you keep every version, you need a way to tell them apart. The three choices are the difference between a warehouse you trust and one you quietly stop using.

warehousekimballscd
ETL from Banner with a watermark Banner to warehouse - incremental ETL extract with a watermark, transform in staging, load idempotently 1 EXTRACT BANNER NBBPOSN NBRPLBD SPRIDEN 2 TRANSFORM STAGING fk resolve surrogate keys SCD Type 2 lookups 3 LOAD WAREHOUSE dim_position fct_position_budget WHERE activity_date > :watermark UPSERT etl_watermark advance on success Each stage is one Windmill step. The watermark is the memory.
Track F · From Banner to a warehouse
ETL from Banner — Moving Data on a Schedule, with Windmill

A warehouse that is not fed fresh data every night is not a warehouse. It is a museum. The difference between the two is a scheduled, repeatable, monitored ETL pipeline — and that pipeline is the only part of the system Banner users ever actually feel.

warehouseetlwindmill
Three-layer semantic model cake BI CONSUMERS — reports & dashboards Argos Power BI Tableau Scorecard drag-and-drop; never write warehouse SQL directly SEMANTIC MODEL — business names & measures Total Budgeted Total Actual Variance Variance % one definition per measure, business vocabulary, security WAREHOUSE — facts & dimensions fct_position_budget dim_position dim_organization dim_date surrogate keys, SCD Type 2, Kimball star data up questions down data up questions down
Track F · From Banner to a warehouse
The Semantic Layer — Where Argos, Power BI, and Dashboards Sit

The warehouse is not the product. The warehouse is the kitchen. The product is the menu — the single curated view of the data that every report writer, every dashboard, every Argos DataBlock consumes. That menu is called the semantic layer, and if you skip it, every consumer rebuilds it from scratch in their own head.

warehousesemantic-layerpower-bi
Three fact table patterns for one applicant Same applicant - three fact table patterns Maria, applicant 38201, modeled at three different grains TRANSACTION 2026-03-15 09:12 inquiry 2026-04-02 14:31 visit 2026-05-18 11:08 submit 2026-06-30 16:05 decision one row per event, append-only. PERIODIC SNAPSHOT entity=38201 month=Jan $58400 entity=38201 month=Feb $58400 entity=38201 month=Mar $58400 entity=38201 month=Apr $58400 one row per (entity x period), taken on schedule. ACCUMULATING SNAPSHOT one applicant row inquiry_dt: Mar 15 app_dt: May 18 decision_dt: ___ enrolled_dt: ___ count: 1 one row per entity, REVISITED as milestones happen. transaction rows append; snapshots either repeat on schedule or revisit one row
Track F · From Banner to a warehouse
The Three Fact-Table Patterns — Transaction, Periodic, Accumulating

A fact table holds measurements. But not all measurements behave the same way. The first design decision when you model a new star is not which columns to include. It is which of three canonical patterns the fact table follows — and picking wrong means building a star that cannot answer the questions the business needs to ask.

warehousekimballfact-patterns
Event factless versus coverage factless Factless facts: events are sparse; coverage is dense one attendance question, modeled as present-only rows or a complete roster Attendance Events event factless Attendance Coverage Roster coverage factless student_key date_key course_key attendance_count 38201 20260901 4287 1 38201 20260902 4287 1 38201 20260904 4287 1 38202 20260901 4287 1 38202 20260903 4287 1 38202 20260905 4287 1 38203 20260902 4287 1 38203 20260903 4287 1 38203 20260904 4287 1 38204 20260901 4287 1 38204 20260902 4287 1 38204 20260905 4287 1 Sparse. Only events present. 'Who was absent?' needs a roster compare. student_key date_key course_key status_key coverage_count 38201 20260901 4287 Present 1 38201 20260902 4287 Present 1 38201 20260903 4287 Absent 1 38201 20260904 4287 Present 1 38201 20260905 4287 Absent 1 38202 20260901 4287 Present 1 38202 20260902 4287 Absent 1 38202 20260903 4287 Present 1 38202 20260904 4287 Absent 1 38202 20260905 4287 Present 1 38203 20260901 4287 Absent 1 38203 20260902 4287 Present 1 38203 20260903 4287 Present 1 38203 20260904 4287 Present 1 38203 20260905 4287 Absent 1 38204 20260901 4287 Present 1 38204 20260902 4287 Present 1 38204 20260903 4287 Absent 1 38204 20260904 4287 Absent 1 38204 20260905 4287 Present 1 Dense. Every scope cell present. 'Who was absent?' is one WHERE filter. same 4 students x 5 days: 12 present events versus 20 coverage rows
Track F · From Banner to a warehouse
Factless Fact Tables — Events and Coverage

Some of the most valuable questions a warehouse can answer have no numbers in them: 'Which students registered for this course?' 'Which classrooms sat empty this term?' 'Which admitted applicants never enrolled?' A fact table with no measures sounds like a contradiction. It is not. It is the cleanest answer to the 'what happened' and 'what did not happen' questions that dollars-and-hours fact tables cannot touch.

warehousekimballfactless

GBuilding the Waubonsee warehouseplaybook

  1. 1Step 1 of 8Pick a Process — Why Position-Budget Is the First StarThe first star is the choice that decides whether the warehouse gets adopted or shelved. For Waubonsee, the evidence picks it for you.warehousekimballfirst-star
  2. 2Step 2 of 8Declare the Grain — One Row Equals One What?The grain is the single most consequential sentence you will write about your warehouse. Get it right and every dimension follows; get it wrong and every report lies in subtle ways for years.warehousekimballgrain
  3. 3Step 3 of 8Build the Date Dimension — One Row Per Day, Three Calendars in One TableEvery star in your warehouse will join to this one dimension. Build it once, get the three calendars right, and never touch it again — except to add holidays.warehousekimballdim-date
  4. 4Step 4 of 8Build the Position Dimension — SCD Type 2 and the Discipline of HistoryA position's title changes — and your warehouse must remember both versions, so a query about last year reports last year's title, not today's. That is Slowly Changing Dimension Type 2, and getting it right once is the difference between a warehouse you trust and one you have to apologize for.warehousekimballdim-position
  5. 5Step 5 of 8Build the Position-Budget Fact — The Center of the First StarEverything in the warehouse exists to support one thing: a fact table you can query without thinking about Banner. This is the step that builds it. After this, an analyst can answer 'budgeted vs actual by department by month' with three joins and no MAX subquery — a five-second query against a star that did not exist yesterday.warehousekimballfact-table
  6. 6Step 6 of 8The ETL Flow — Wiring the Load into WindmillThe fact table is built. The load query works. Now the harder question: how does it RUN every month, unattended, recoverable, monitored — at 02:00 while you are asleep? Windmill is the stage manager. The flow is the cue sheet. This step turns the load you wrote in G5 into a piece of infrastructure that just works.warehouseetlwindmill
  7. 7Step 7 of 8Validate Against Banner — Agree to the Cent or StopThe warehouse is loaded. The flow runs every month. The dashboards render. None of it matters if the numbers do not match Banner. The first time a CFO sees a difference between the warehouse's number and the Banner Position Control report, the warehouse loses — every time, every institution, no exceptions. Reconciliation is the discipline that prevents that conversation from happening.warehousereconciliationvalidation
  8. 8Step 8 of 8The Second Star — Admissions as an Accumulating SnapshotThe second star is not 'another star like the first.' If G5 was a periodic snapshot, the second star should teach a DIFFERENT fact pattern — otherwise you have learned half the dimensional vocabulary at twice the cost. For Waubonsee, the deliberate second star is Admissions as an **accumulating snapshot** — one row per applicant, multiple date_keys filling in as milestones happen. And the moment you build it, the warehouse's bus matrix appears: `dim_date` is shared across both stars, and the foundation for every star that follows is laid.warehousekimballaccumulating-snapshot

HDataBlock architecture & engineering decisions

One-to-one versus consolidated decision matrix Decision Criterion 1:1 Favored When... Consolidated Favored When... Change frequency SQL is stable; rare edits business logic shifts often; need to propagate quickly Report similarity reports share <50% of SQL reports share >80% of SQL Performance sensitivity executive dashboards, real-time queries monthly batches, off-hours Team maturity newer team; less test discipline experienced; documented change mgmt Governance capacity small catalog (<100) large catalog (>300) Neither pattern is universally correct. The framework asks which criteria dominate at your campus.
Track H · DataBlock architecture & engineering decisions
One DataBlock Per Report, or One for Many? The Decision Framework

An Argos shop with 500 reports and 500 DataBlocks has a maintenance problem. An Argos shop with 500 reports and 80 DataBlocks has a complexity problem. Neither answer is wrong. But the choice between them — one DataBlock per report, or one DataBlock serving many — is the most consequential architectural decision a Banner reporting team makes after choosing Argos itself. Here is the framework for making it deliberately.

argosdatablockarchitecture
Weighted similarity score anatomy SQL TOKEN JACCARD 40% weight set A set B select from where sfrstcr spriden pidm term code select from where sfrstcr spriden pidm major level x 0.40 0.75 TABLE JACCARD 30% weight set A set B spriden sgbstdn sfrstcr spriden sgbstdn shrgrde x 0.30 0.67 FIELD JACCARD 20% weight set A set B student_id last_name major student_id last_name crn x 0.20 0.67 PARAM JACCARD 10% weight set A set B :main_DD_term :main_DD_term x 0.10 1.00 weighted sum = 0.40 x sql + 0.30 x tbl + 0.20 x fld + 0.10 x par = 0.75 x 0.40 + 0.67 x 0.30 + 0.67 x 0.20 + 1.00 x 0.10 = 0.73
Track H · DataBlock architecture & engineering decisions
Finding Consolidation Candidates — Programmatic Similarity Across the Catalog

Waubonsee's Argos catalog has ~670 DataBlocks. Some of them are near-duplicates of each other — same SQL shape, same tables, same fields, one filter different. Finding them by hand means eyeballing 670 × 669 / 2 ≈ 224,000 pairs. A similarity tool can scan the whole catalog in seconds and surface the top candidates ranked by what matters. Here is how it works, what it found, and what to do with the list.

argosdatablockconsolidation
Safe consolidation migration phases Every phase has a rollback path. No big-bang cutovers. 1 INVENTORY 2 SHADOW BUILD 3 VERIFY 4 CUTOVER 5 DEPRECATION list every consumer OLD DB1 DB2 DB3 DB4 DB5 NEW consol. DB old and new alive in parallel OLD NEW DIFF outputs must be identical OLD NEW R1 R2 R3 one consumer per week OLD OLD OLD archive after confidence window weeks to months - pace is per consumer, not per catalog
Track H · DataBlock architecture & engineering decisions
Safe Consolidation Migration — How to Merge N DataBlocks into One Without Breaking Anyone

The decision to consolidate has been made. The candidates have been identified. Now comes the part where things actually break — rewiring consuming reports to a new DataBlock without the numbers drifting, without a user opening a report to wrong totals, without an emergency rollback nobody has practiced. The safe pattern is not one big swap. It is five sequential phases, every one reversible, and a rule that the old DataBlocks stay alive until the new one has earned every consumer's trust.

argosdatablockconsolidation
When one-to-one DataBlocks win 1:1 PATTERN CONSOLIDATED PATTERN fewer files -> <- lower blast radius DB1 R1 DB2 R2 DB3 R3 DB4 R4 DB5 R5 5 files - 5 owners - 5 blast radii of 1 consolidated DB branch A branch B branch C branch D branch E 1 file - 5 owners - 1 blast radius of 5 R1 R2 R3 R4 R5 Both patterns are valid. 1:1 has higher file count and lower per-change blast radius. Consolidated has fewer files and higher coordination cost per change.
Track H · DataBlock architecture & engineering decisions
When 1:1 Wins — The Case for One DataBlock Per Report

H1 framed the debate neutrally. H2 surfaced the consolidation candidates. H3 wrote the careful migration recipe. This article steps back from the neutrality and makes the contrarian case: in most Argos catalogs, **one DataBlock per report is the right default**. Not because consolidation is wrong — it is sometimes right — but because the costs of consolidation are systematically underestimated, and the benefits of 1:1 are systematically undersold. Here is the defense.

argosdatablockarchitecture
H5 - Argos Similarity workflow RECOMMENDED WORKFLOW - top to bottom 1 Orphans first Zero Report consumers - retire after live-catalog confirm 2 Low-cost CLUSTERS Sort clusters CSV by cost_band asc - work cluster by cluster 3 Low-cost PAIRS not in low clusters Pairs CSV cost_band=low - individual safe drops 4 Medium - judgment call Read SQL side by side - merge when it aligns with current work 5 High - usually do NOT touch Many Reports / aliases / calc orphans / filter divergence 6 Mega-cluster trap Cluster size 50+ with alias_core=0 - raise threshold to 0.70 Re-run after every Argos export - duplicates accumulate.
Track H · DataBlock architecture & engineering decisions
Running Argos Similarity v2.3 — the operational guide

H2 explains the architecture. This article tells you how to actually use the tool — what to run, what to read first, what to ignore, and what the tool quietly cannot see. Follow the recommended workflow (orphans first, low clusters next, low pairs after that, most high-cost pairs never) and the tool's output becomes a backlog you can act on in a single sprint instead of a thousand-row spreadsheet that nobody opens.

argosdatablockconsolidation

IBeyond direct SQL — Ethos & the integration layer

Ethos product taxonomy External systems CRM Slate Workday Argos partners Ethos Integration cargo terminal iPaaS Ethos Identity customs WSO2 federation Ethos Data dashboard analytics warehouse contract boundary EEDM specification Canonical JSON schemas - the data contract Banner Oracle schemas - PIDM - 7-letter tables reads + writes
Track I · Beyond direct SQL — Ethos & the integration layer
What Ethos actually is — one stack, three products, one spec, two brand names

Ellucian renamed Ethos to 'Ellucian Platform' in 2026 — but the airport still lands the same planes through the same gates.

ethosintegrationeedm
Two-step bearer auth flow API key (passport) long-lived portal-issued kept secret ABC123...DEF POST /auth exchange no JSON envelope raw JWT in body ~5 min TTL /api/* calls many calls reuse Bearer ${JWT} until the token expires API key JWT (boarding pass) 401 on expired -> re-call /auth
Track I · Beyond direct SQL — Ethos & the integration layer
EEDM REST mechanics — passport, boarding pass, version-pinned gate

Your passport never goes through the gate. You exchange it once at security for a boarding pass that expires in five minutes — and re-exchange whenever it does.

ethoseedmrest
GUID anatomy - 36 chars, RFC 4122 c2a8e5f3-9d7e-4b18-a4c2-7e1f8b3c9d12 8 hex 4 hex 4 hex 4 hex 12 hex 36 chars total - RFC 4122 - globally unique by construction Column on entity table XYZ_GUID SOMETHING_GUID Shadow table GORICCR_GUID beside GORICCR GORGUID (catch-all) resource type + key most common in Banner usually lands here
Track I · Beyond direct SQL — Ethos & the integration layer
GUIDs vs PIDM — the impedance Banner SQL writers feel first

When an Ethos response lands on your desk, you can't join it on PIDM. You need GORGUID first.

ethosguidpidm
Six workloads x three factors Workload Endpoints Deploy Horizon Tool Security audit GURACLS, GOBEACC inside on-prem short Direct SQL / Argos Ethos if SaaS Ad-hoc analytical report 10+ table joins inside on-prem short Direct SQL / Argos Ethos if SaaS Transcript import external -> Banner outside SaaS long Ethos write via API CRM sync Banner -> Salesforce/Slate outside SaaS long Ethos border crossing Real-time event subscription outside SaaS long Ethos push event path Transfer-credit articulation outside SaaS long Ethos maintenance APIs Above stays inside Banner when on-prem. Below crosses a border, so Ethos wins.
Track I · Beyond direct SQL — Ethos & the integration layer
When Ethos, when SQL — the decision frame for the next 3-5 years

Direct SQL is your private courier — fast, knows your roads, never crosses borders. Ethos is international cargo — slow, paperwork-heavy, but reaches anywhere. In Banner SaaS the back door has no key, so you ship by cargo.

ethossqlargos
Transfer import check-then-write loop External source External SIS / transfer tool courses on a transcript Ethos /qapi/ family QUERY (read-only) qapi/transfer-course-articulation qapi/transfer-course-detail-maint qapi/transfer-equivalent-course /api/transfer-* REGISTER (writes) api/transfer-maintenance api/transfer-course-detail-maint api/transfer-equivalent-course if not found for each course on the transcript Banner tables SHRTRIT SHRTRCR SHRTRCE
Track I · Beyond direct SQL — Ethos & the integration layer
Transcript import end-to-end — customs at the EEDM port

An incoming transcript is cargo at customs. Every course needs a tariff classification — check the schedule first, file a new one if needed, then issue the import permit. Ethos exposes both desks.

ethostranscripttransfer-credit
No article matches that.
← All concepts
Track A · What is it, really

PIDM — The Number Behind Every Person

Every person in Banner has two names: the one you see, and the one the database uses. The one the database uses is a number you were never meant to know about. It is called PIDM, and it is the single most important column in every SQL query you will ever write against Banner.

7 min readbannerpidmspridenjoinsfoundation
The hook

Every person in Banner has two names: the one you see, and the one the database uses. The one you see is printed on pay stubs, class rosters, vendor checks — a readable string like "Smith, John A." or a visible 8-digit Banner ID like 00123456. The one the database uses is a number you were never meant to know about. It is an internal surrogate: NUMBER(8), system-generated, invisible to end users, and utterly non-negotiable. It is called PIDM — Personal ID Master — and it is the single most important column in every SQL query you will ever write against Banner.

The everyday analogy

When you sign up for a library card, the librarian types your name and address into the system and the computer assigns you patron number #54287. That number is meaningless to you. You carry a physical card with your name on it. The librarian greets you by name. The receipt prints your name. But internally, every book you have ever checked out, every fine you have ever paid, every inter-library loan you have ever requested, is recorded against patron #54287 — not against "Jane Smith."

A library patron card on a wooden counter; behind it, a card-catalog drawer of borrowing history — every slip keyed on patron number #54287, not the name. The name on the card is what the world sees; the number is what the system uses.
A library patron card on a wooden counter; behind it, a card-catalog drawer of borrowing history — every slip keyed on patron number #54287, not the name. The name on the card is what the world sees; the number is what the system uses.

Then you get married and change your name. The card gets re-issued with "Jane Cortez" on it. The address on file changes. Maybe the visible 16-digit barcode on the card even gets replaced when the library switches to a new card design. None of those changes touch your borrowing history. Patron #54287 is still patron #54287. The librarian re-types your current name into the system, the receipts now say "Cortez," but the loan records — every book you checked out before the name change and every book you will check out after — still join on the same patron number. Silently, invisibly, perfectly.

That patron number is PIDM. Your last name is SPRIDEN_LAST_NAME. Your visible library card barcode is SPRIDEN_ID. The librarian's re-typing your new name is an insert into SPRIDEN — the old name row gets flagged as historical (SPRIDEN_CHANGE_IND = 'N' for name change), the new name row is the current one (SPRIDEN_CHANGE_IND IS NULL). Banner uses PIDM because names change, visible IDs can be corrected or re-issued, and Social Security Numbers are things you actively try to avoid joining on. The invariant — the one thing that must never shift under you — has to be a number nobody else knows about.

What it really is

PIDM is Banner's internal surrogate key for every person and non-person entity in the system. It is NUMBER(8), generated once by Banner when an entity is first inserted, and it never changes and is never reused for the life of that entity. A student, an employee, a vendor, an applicant — every entity gets exactly one PIDM, and that PIDM stays with it forever.

SPRIDEN is the translation table. It sits between the raw PIDM and the human-readable world. It stores one row per name version per entity — so a person who has changed names (marriage, divorce, legal name change) appears in SPRIDEN multiple times. All but the current row carry a SPRIDEN_CHANGE_IND value: 'N' for a name change, 'I' for an identification change (a typo correction in the visible ID, a merge). The current row — the one that represents the person's name right now — has SPRIDEN_CHANGE_IND IS NULL.

SPRIDEN_ENTITY_IND tells you what kind of entity the PIDM represents: 'P' for a person, 'C' for a company or corporation. Vendor records like FTVVEND use 'C' often, because the vendor might be a business rather than an individual. You filter on this column every time you join to SPRIDEN for people, or you risk mixing companies into your student roster.

PIDM as the universal join column PIDM is the universal join column every person-bearing table carries the same person key SPRIDEN identity + name history spriden_pidm SFRSTCR course registration sfrstcr_pidm SGBSTDN general student sgbstdn_pidm GOBEACC user account gobeacc_pidm PIDM integer - one per person one column - four tables - one shared person
SPRIDEN at the center — the one table that holds the current name and visible ID. Rings of Banner tables (SGBSTDN, SFRSTCR, PEBEMPL, NBRJOBS, FTVVEND) all reach back to the same PIDM, regardless of the role the person is playing.

The same PIDM is shared across roles. PIDM 38201 might be a student in SGBSTDN (the student base table), an employee in PEBEMPL (the HR employee table), AND a vendor in FTVVEND — all simultaneously. The student-worker who also sells handmade crafts to the bookstore is one person, one PIDM, three roles. Every table that records anything about a person joins back to SPRIDEN on PIDM — not on name, not on the visible Banner ID, not on SSN. The visible ID (SPRIDEN_ID) is for humans: registrars type it, Argos prompts ask for it, reports display it. PIDM is for joins: fast, stable, anonymous.

See it — the diagram

The ring diagram makes the relationship visible. SPRIDEN sits at the center — the one table that knows a PIDM's current name, current visible ID, and current entity type. Radiating outward are the role-specific tables: student records, HR records, finance records. Every arrow points inward, toward the same PIDM. The diagram says what a thousand words of documentation would say: to ask a question about a person in Banner, you start at the role table, join to SPRIDEN on PIDM, and filter by change indicator and entity type. The pattern repeats identically across the entire ERP.

Show me the code

The canonical PIDM resolution — from a raw PIDM to the current name — is three lines of SQL and two filters you must never omit:

-- Get the CURRENT name for a given PIDM.
-- The two WHERE filters are not optional: without change_ind you get
-- duplicates (one row per historical name version), without entity_ind
-- you may catch a company that shares the PIDM number space.
SELECT s.spriden_pidm,
       s.spriden_id,
       s.spriden_last_name || ', ' || s.spriden_first_name AS full_name
FROM   spriden s
WHERE  s.spriden_change_ind IS NULL
  AND  s.spriden_entity_ind = 'P';

Now use it in a real query — a course roster for a specific term. The student registration table (SFRSTCR) carries the PIDM; SPRIDEN provides the name:

-- Roster of students in a specific course-section in Spring 2026.
-- The fact table (sfrstcr) joins to spriden on PIDM, never on ID.
SELECT s.spriden_id           AS student_id,
       s.spriden_last_name    AS last_name,
       s.spriden_first_name   AS first_name,
       r.sfrstcr_crn           AS course_ref_number,
       r.sfrstcr_credit_hr     AS credit_hours
FROM   sfrstcr r
JOIN   spriden s
       ON  s.spriden_pidm        = r.sfrstcr_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  r.sfrstcr_term_code = '202610';

The join is on PIDM — not on SPRIDEN_ID, not on SPRIDEN_LAST_NAME, not on any column a human types. The two filter conditions are inside the JOIN, not in a WHERE — they are part of the join contract, not an afterthought. When a query needs to resolve a second person — an advisor, a supervisor, a reporting manager — you join SPRIDEN a second time with a different alias. That pattern is covered in The Double SPRIDEN — Naming Two People in One Query.

Where intuition fails

Five lessons that every Banner SQL writer learns the hard way:

  1. Never join people by name. There are multiple "Smith, John" records in

any college database. Names have typos. Accents come and go across systems. Suffixes ("Jr.", "III") are inconsistent. A name is a label, not a key. PIDM is the key. Join on it every time.

  1. Never join people by SSN. SSNs are regulated PII — every copy of an SSN

in your query, your logs, your result set is an audit liability. Many Banner records have missing or placeholder SSNs (999-XX-XXXX patterns for international students, for example). PIDM exists specifically so you never have to touch SSN in a join. Use it.

  1. **SPRIDEN_CHANGE_IND IS NULL is mandatory.** Without this filter, anyone

who has ever changed names — marriage, divorce, legal correction — returns duplicate rows in your result set. One row for every historical name version. A roster of 25 students silently becomes 31 rows, and the duplicates look identical except for the name column. The Banner Semantic Search SQL Explainer flags this missing filter as a warning. SPRIDEN Without CHANGE_IND — The Duplicate-Name Trap covers the full duplicate-name gotcha, with examples.

  1. A single PIDM wears multiple hats. A student who works part-time on

campus and also sells handmade goods to the bookstore is one person, one PIDM, and rows in SGBSTDN (student), PEBEMPL (employee), and FTVVEND (vendor) simultaneously. If you join all three tables naively on PIDM without filtering by role, you produce a Cartesian mess — every student row multiplied by every employee row multiplied by every vendor row. Join one role table at a time, or use EXISTS to check for role membership without multiplying rows.

  1. PIDM is not for display. Users see the 8-digit Banner ID

(SPRIDEN_ID). Your reports must translate PIDM back to SPRIDEN_ID before showing anything to a user. Never print a raw PIDM in a report, a dashboard, or an export. PIDM is an internal surrogate — it is not a student ID, not an employee ID, not a vendor number. Leaking it to the UI is a privacy concern, and it confuses users who expect to see the visible Banner ID they recognize. The join uses PIDM; the SELECT shows SPRIDEN_ID.

The one-sentence takeaway

PIDM is Banner's internal person number. It never changes, it never repeats, and it is the only thing you should ever join on.

← All concepts
Track A · What is it, really

Reading a Banner Table Name — The Seven-Letter Code

You see SPRIDEN, SFRSTCR, NBBPOSN, GOBEACC every day. They look like random seven-letter strings. They are not. Each one is a road map — and once you can read it, you can guess what domain any Banner table belongs to without opening a data dictionary.

6 min readbannertable-namingconventionschemaprefixellucian
The hook

You see SPRIDEN, SFRSTCR, NBBPOSN, GOBEACC every day. They look like random seven-letter strings. They are not. Each one is a road map — and once you can read it, you can guess what domain any Banner table belongs to without opening a data dictionary.

The everyday analogy

Type a U.S. ZIP code into a search box — 60134. To anyone who knows the convention, that string encodes structure. The first digit (6) identifies a large region of the country: 0 for the Northeast, 1 for upper New York/Pennsylvania, 2 for the Mid-Atlantic, 6 for the Midwest including Illinois. The next two digits (01) narrow to a sectional center: northern Illinois. The last two digits (34) identify a local post office: Geneva, Illinois. Five characters, three levels of geographic precision, all encoded so that a sorter reading them left to right gets progressively more specific.

A postal sorter's wall map of the US showing ZIP code regions colored by first digit; below it, a sorted stack of envelopes with ZIP codes highlighted in coral; alongside, a small handwritten card showing `SPRIDEN` decoded into 'S = Student / PR = Person / IDEN = identification'.
A postal sorter's wall map of the US showing ZIP code regions colored by first digit; below it, a sorted stack of envelopes with ZIP codes highlighted in coral; alongside, a small handwritten card showing SPRIDEN decoded into 'S = Student / PR = Person / IDEN = identification'.

Banner table names are the same trick applied to data domains. Seven characters, encoded so that a SQL writer reading them left to right gets progressively more specific. The first letter is the "ZIP region" — it tells you the system area: Student, Finance, Payroll, Position Control. The next two letters narrow to the application within that system. The remaining letters name the object the table stores. By the time you have read all seven, you know what domain the table belongs to, what application produced it, and what kind of record it holds — all without looking anything up.

Like ZIP codes, the convention is not perfect. Some prefixes are exceptions (the G for General is the cross-system catch-all, like ZIP codes that don't follow the regional grid cleanly). Some installations have custom tables that don't follow the rule at all (like a private courier service with its own routing codes). But the overwhelming majority of Banner tables follow the seven-letter rule, and learning to read it pays for itself the first day.

What it really is

Banner table names follow a positional convention — typically 7 characters, occasionally 6 or 8 for older or newer additions. Standard format: SAAOOOO.

  • Position 1 — System area (one letter). The most useful single character. S = Student (registration, academic history, admissions, course catalog, advising). F = Finance (general ledger, accounts payable, purchasing, grants). T = Accounts Receivable / Bursar (student finance). P = Payroll (wage history, deductions, pay events). N = Position Control (HR positions, labor distribution, jobs). R = Research / Financial Aid (RPRAWRD for award). G = General (cross-system: security, addresses, audit). A = Alumni / Advancement. W = Custom institutional tables (the convention at most installations; not part of Banner's distributed schema).
  • Positions 2-3 — Application within the system. Varies by area. In Student: B for Banner base, F for Registration (SFRSTCR), G for General Person/Student (SGBSTDN, SGRADVR), H for Academic History (SHRGRDE), R for Course catalog (SCBCRSE), P for Person (SPRIDEN, SPRADDR). In Finance: TV for validation, TB for base tables.
  • Positions 4-7 — Object. Names the specific record. B* = base/master table (NBBPOSN). R* = repeating/detail/rules table. TV = validation lookup (STVTERM, STVMAJR). V* = view. Common suffixes: IDEN (identification), EMPL (employee), STDN (student), POSN (position), TERM (term), CRSE (course).

There is a valuable sub-convention: any table name with TV in the middle two positions (*TV*) is a validation lookup table. STVTERM = Student validation TERM. GTVZIPC = General validation ZIP Code. PTVPDIS = Payroll validation disposition. See STV* and GTV* — Banner's Code Dictionaries for the deep dive.

Banner table-name prefix anatomy Banner table names carry three positional clues letter 1 = system area, letters 2-3 = application, rest = object S PR IDEN SPRIDEN S = Student | PR = Person record | IDEN = identification S FR STCR SFRSTCR S = Student | FR = Course registration | STCR = student course N BB POSN NBBPOSN N = Position Control | BB = Budget/base | POSN = position F TV ORGN FTVORGN F = Finance | TV = validation table | ORGN = organization system area S Student F Finance N Position Control G General read Banner names left to right: area, application, object
Four real Banner table names (SPRIDEN, SFRSTCR, NBBPOSN, FTVORGN) decoded into their three positional parts (system area / application / object), each part colored to match a system-area legend on the side.

The convention is consistent enough that you can decode a new table name in seconds. NBRJOBSN = Position Control, BR = Base Rules, JOBS = jobs (the position-to-job assignment table). FTMFUNDF = Finance, TM = Transaction Management, FUND = fund. RPRAWRDR = Research/Financial Aid, PR = Prospective, AWRD = award. You don't need a data dictionary to know which domain a table lives in.

See it — the diagram

Four Banner table names decoded into their three positional parts, each part color-coded: system area (coral), application (ink), object (medium gray). A system-area legend along the right side shows all eight system letters with their full names. SPRIDEN splits into S / PR / IDEN (Student → Person → identification). SFRSTCR splits into S / FR / STCR (Student → Faculty/Registration → student course registration). NBBPOSN splits into N / BB / POSN (Position Control → Base Banner → position). FTVORGN splits into F / TV / ORGN (Finance → validation table → organization). The visual makes the positional structure obvious: reading left to right, the domain gets more specific — exactly like the ZIP code on the envelope on the facing page.

Show me the code

Decode real Banner table names by walking through their prefixes:

SPRIDEN  →  S  = Student system
            PR = Person record
            IDEN = identification
         (current name + ID per person)

SFRSTCR  →  S  = Student
            FR = Faculty/Registration
            STCR = student course registration

NBBPOSN  →  N  = Position Control
            BB = Base Banner (position master)
            POSN = position

FTVORGN  →  F  = Finance
            TV = validation table
            ORGN = organization

PHRHIST  →  P  = Payroll
            HR = HR History
            HIST = history (per pay event)

GOBEACC  →  G  = General (cross-system)
            OB = Banner Object
            EACC = e-account (security user)

STVTERM  →  S  = Student
            TV = validation table
            TERM = term code lookup

SCBCRSE  →  S  = Student
            CB = Course Base
            CRSE = course

The exercise is the point: decoding the prefix tells you what domain, what application, what kind of record — without any data dictionary lookup. Once you've decoded ten of these, the eleventh is guessable.

Where intuition fails
  1. The prefix does NOT always tell you the schema. GOBEACC starts with G (suggesting General data), and indeed it lives in the GENERAL schema — but the rule is not universal. Some G-prefixed tables live in SATURN because they predate the schema split. The only reliable schema lookup is ALL_TABLES.OWNER or the BSS schema search. See Schemas — Which Drawer the Table Lives In.
  1. Custom tables don't always follow the rule. Institution-specific tables created by a Waubonsee developer might use any prefix — common conventions are W* or Z*, but local developers sometimes use Banner-style prefixes that accidentally collide with future Ellucian additions. Check the BSS schema search when you encounter an unfamiliar table.
  1. Some prefixes overlap by accident. SPRADDR starts with S (Student system) but stores person addresses, which are cross-system in practice. The prefix tells you where the table was originally housed; the table's actual use may be broader. SPRIDEN itself is S-prefixed but is the cross-system person-identification table that every system joins through.
  1. The 4-letter object suffix is not always a noun. Some suffixes are abbreviations (IDEN for identification, EMPL for employee, STDN for student). Others are exact words (POSN for position, TERM for term, CRSE for course). Learn the common ones and the rest become guessable — none are truly random.
  1. **The *TV* sub-convention is the most reliable rule.** Any Banner name with TV in positions 2-3 is a validation lookup table. STV*, GTV*, PTV*, FTV*, NTV*, ATV* — the system-area letter changes but the TV middle holds across every system. This is the first thing to check when you see an unfamiliar table name.
The one-sentence takeaway

Banner table names are seven-letter positional codes. The first letter is the system area (S=Student, F=Finance, P=Payroll, N=Position Control, G=General, R=Research/Aid, T=AR, A=Alumni). Letters 2-3 narrow to the application. The rest name the object. Read one, and you know what domain it belongs to before you even look at the columns.

← All concepts
Track A · What is it, really

STV* and GTV* — Banner's Code Dictionaries

Banner stores codes — not names, not descriptions, not the words a human reads. SGBSTDN_MAJR_CODE_1 = 'BIO', SFRSTCR_RSTS_CODE = 'RE', STVTERM_CODE = '202610'. The code is compact, efficient, and completely opaque. The translation is in a second set of tables — the STV and GTV dictionaries — and if you don't know they exist, you're reading a foreign language without the dictionary.

6 min readbannervalidation-tablesstvtermstvmajrgtvlookupdictionary
The hook

Banner stores codes — not names, not descriptions, not the words a human reads. SGBSTDN_MAJR_CODE_1 = 'BIO', SFRSTCR_RSTS_CODE = 'RE', STVTERM_CODE = '202610'. The code is compact, efficient, and completely opaque. The translation is in a second set of tables — the STV and GTV dictionaries — and if you don't know they exist, you're reading a foreign language without the dictionary.

The everyday analogy

Open any college textbook to chapter 4. The text uses abbreviations and codes freely: "the FRC group," "Type II diabetes," "Class B amplifier," "the SR latch." The reader follows along, but every few pages they hit a code they don't recognize. They flip to the glossary at the back of the book. Alphabetical, one entry per term, one short definition each. "SR latch — set/reset bistable circuit." "FRC — Free Radical Capture." The text uses codes for brevity; the glossary defines them in one place; the reader joins one to the other in their head as they read.

An open college textbook with chapter 4 visible on the left page (text peppered with abbreviations like 'SR latch' and 'FRC group') and the alphabetical glossary on the right page (the same terms defined); a reader's finger marking the glossary entry.
An open college textbook with chapter 4 visible on the left page (text peppered with abbreviations like 'SR latch' and 'FRC group') and the alphabetical glossary on the right page (the same terms defined); a reader's finger marking the glossary entry.

Banner has the same structure. The data tables (SGBSTDN, SFRSTCR, PHRHIST) use codes everywhere — major codes, registration status codes, disposition codes, fund codes, organization codes. The validation tables (STVMAJR, STVRSTS, PTVPDIS, FTVFUND, FTVORGN) are the glossary: one row per code, with the description. To get a human-readable major name out of a student record, you join SGBSTDN to STVMAJR on the major code — the reader's eye flipping to the back of the book, made into a SQL JOIN.

The convention is so consistent that once you recognize the middle-letter pattern (TV in any Banner name), you know exactly what kind of table you are dealing with and how to use it. No guessing.

What it really is

The ***TV* convention** is Banner's most reliable naming rule. Any table name with TV in positions 2-3 is a validation table — a code dictionary. The system-area letter still applies: STV* = Student system validation, GTV* = General, PTV* = Payroll, FTV* = Finance, NTV* = Position Control, ATV* = Alumni.

A validation table typically has at least these standard columns:

  • <TABLE>_CODE — the primary key, the code value ('BIO', 'RE', '202610')
  • <TABLE>_DESC — the human-readable description ('Biology', 'Registered', 'Fall 2026')
  • <TABLE>_ACTIVITY_DATE — last modified timestamp
  • Often additional metadata: _VALID_*_IND flags (valid for Admissions, valid for Recruitment, etc.), sort order columns, parent-code columns for hierarchies, active/obsolete flags

**STV* (Student validation)** — the most numerous family. STVTERM (terms), STVMAJR (majors), STVRSTS (registration statuses), STVSTST (student statuses), STVRELG (religious affiliations), STVNATN (nations) — hundreds of these tables. See TERM Codes — The Academic Timestamp Banner Uses Everywhere for the deep dive on STVTERM.

**GTV* (General validation)** — cross-system code dictionaries. GTVZIPC (ZIP codes), GTVSDAX (cross-walk to external systems), GTVINSTITUTION (sister/parent institutions).

**PTV, FTV, NTV*, ATV*** — same pattern in their respective systems. PTVPDIS for payroll disposition (see PHRHIST Without DISP — In-Progress vs Posted Payroll). FTVORGN for finance organizations, FTVFUND for fund codes.

Validation table join from SGBSTDN to STVMAJR Validation tables translate compact codes SGBSTDN STVMAJR pidm 38201 term_eff '202610' majr_code_1 'BIO' levl_code 'UG' stvmajr_code 'BIO' desc 'Biology' valid_a_ind 'Y' JOIN ON code LEFT JOIN stvmajr ON stvmajr_code = sgbstdn_majr_code_1 the join translates the code to its description
Left side: a SGBSTDN row showing major_code='BIO' (coral cell); right side: the STVMAJR validation row for 'BIO' showing desc='Biology' (coral cell); a coral JOIN arrow connecting the two cells, with JOIN stvmajr ON stvmajr_code = sgbstdn_majr_code_1 underneath.

The join pattern is universal. Every roster query, every financial summary, every enrollment report joins through at least one validation table to convert codes to descriptions. The validation table is usually small (a few hundred rows at most) and well-indexed on the code column — joins are inexpensive. The data table holds the code; the validation table holds the description; the SQL JOIN is the reader's finger on the glossary page.

See it — the diagram

A single SGBSTDN row on the left, one cell highlighted in coral: SGBSTDN_MAJR_CODE_1 = 'BIO'. A coral JOIN arrow arcs to the right, where a single STVMAJR row sits, one cell highlighted in the same coral: STVMAJR_DESC = 'Biology'. The join condition sits written beneath the arrow: JOIN stvmajr ON stvmajr_code = sgbstdn_majr_code_1. Below the diagram, a rendered result row shows what the user sees: "Student ID: 900123456, Last Name: Chen, Major: Biology" — the code is gone, the description is present. The visual formula is one diagram that generalizes to every _CODE column in Banner.

Show me the code

Look up a single code:

-- What does 'BIO' mean as a major code?
SELECT stvmajr_code, stvmajr_desc, stvmajr_valid_a_ind
FROM   stvmajr
WHERE  stvmajr_code = 'BIO';

Join a data table to its validation table for the human-readable label:

-- Students by major NAME, not just code.
-- The join through STVMAJR is the "flip to the glossary" step.
SELECT s.spriden_id           AS student_id,
       s.spriden_last_name    AS last_name,
       m.stvmajr_desc         AS major
FROM   sgbstdn g
JOIN   spriden s
       ON  s.spriden_pidm        = g.sgbstdn_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
LEFT JOIN stvmajr m
       ON m.stvmajr_code = g.sgbstdn_majr_code_1
WHERE  g.sgbstdn_term_code_eff = '202610';

List every active code in a validation table (useful for populating an Argos dropdown):

-- Active terms only — the dropdown's source list.
SELECT stvterm_code, stvterm_desc
FROM   stvterm
WHERE  stvterm_start_date <= SYSDATE + 365
  AND  stvterm_end_date   >= SYSDATE - 365
ORDER BY stvterm_code DESC;

Find which validation table serves a given code column: Match the suffix. SGBSTDN_MAJR_CODE_1 → look for STVMAJR. SFRSTCR_RSTS_CODE → look for STVRSTS. PHRHIST_DISP → look for PTVPDIS. The naming convention from Reading a Banner Table Name — The Seven-Letter Code makes this predictable: the column suffix and the validation table name are the same root.

Where intuition fails
  1. **Use LEFT JOIN, not JOIN, when joining to a validation table.** Some data rows carry codes that no longer exist in the validation table (codes retired without cleaning up the data). An inner join silently drops those rows. A LEFT JOIN keeps them and shows the description as NULL — which you can COALESCE to 'Unknown' for the report. Dropping rows because their code is stale is silent data loss.
  1. Validation tables sometimes have per-system validity flags. STVMAJR_VALID_A_IND says whether the major is valid for the Admissions system. STVMAJR_VALID_R_IND says whether it is valid for Recruitment. A code may be valid in one system and not another. If you are filtering for "available majors in the Recruitment dropdown," check the right _VALID_*_IND.
  1. Some validation tables are hierarchical. STVMAJR has STVMAJR_DEPT_CODE (parent department) and STVMAJR_COLL_CODE (parent college). The validation table itself encodes the org-chart of majors. Use the parent columns for grouping reports without joining to a separate STVDEPT or STVCOLL table.
  1. The same code can mean different things in different columns. 'A' in SGBSTDN_STST_CODE means "Active Student"; 'A' in PEBEMPL_EMPL_STATUS means "Active Employee"; 'A' in SFRSTCR_RSTS_CODE might mean "Approved" depending on the installation. The code is scoped to its own validation table. Never compare codes across columns without joining each through its own STV/GTV.
  1. Inactive / obsolete codes still appear in old data. Banner does not retroactively rename codes when validation table entries are deactivated. A student from 2015 may still have SGBSTDN_MAJR_CODE_1 = 'ENGZZ' (an obsolete engineering placeholder) even though STVMAJR no longer marks 'ENGZZ' as valid. The join still finds the row; the _VALID_*_IND flag tells you it is inactive. See also Soft Deletes — The Rows That Aren't Really Gone.
The one-sentence takeaway

Every Banner column ending in _CODE joins to an STV or GTV validation table. The convention is universal: *TV* in any Banner table name means validation lookup. Join through them with LEFT JOIN to convert opaque codes into human-readable descriptions — the code is the data, the validation table is the glossary at the back of the book.

← All concepts
Track A · What is it, really

Schemas — Which Drawer the Table Lives In

You type SELECT * FROM gobeacc in your SQL editor. Oracle returns ORA-00942: table or view does not exist. The table definitely exists — you saw it in BSS. The problem is not whether it exists. The problem is which drawer it lives in.

7 min readbanneroracleschemasaturngeneralsynonymgrantnamespace
The hook

You type SELECT * FROM gobeacc in your SQL editor. Oracle returns ORA-00942: table or view does not exist. The table definitely exists — you saw it in BSS. The problem is not whether it exists. The problem is which drawer it lives in.

The everyday analogy

Walk into the records room of any office that still keeps paper files. The wall is lined with filing cabinets. Each cabinet has multiple drawers, and each drawer has a label: "Personnel A-G," "Personnel H-M," "Personnel N-Z," "Vendor Contracts," "Old Tax Returns 2010-2019."

Now imagine you are sent to find the personnel file for "Margaret Chen." You walk in knowing two things: her name, and that you need her personnel file. You can find it because you know the file is in the personnel drawers, and you know alphabetical order. But if you didn't know it was in the "personnel" drawers, you might pull the "Vendor Contracts" drawer first, find nothing, and conclude "Margaret Chen has no file" — when really the file exists, you were just searching the wrong drawer.

A wall of vintage wooden filing cabinets with labeled drawers ('SATURN', 'GENERAL', 'PAYROLL', 'FIMSMGR'); one drawer pulled out showing folders (table names) inside; a small index card sticking out reading 'GOBEACC → GENERAL drawer'.
A wall of vintage wooden filing cabinets with labeled drawers ('SATURN', 'GENERAL', 'PAYROLL', 'FIMSMGR'); one drawer pulled out showing folders (table names) inside; a small index card sticking out reading 'GOBEACC → GENERAL drawer'.

Banner is the records room. Each Oracle schema is a drawer. The drawers are labeled — SATURN, GENERAL, PAYROLL, FIMSMGR. The tables are the folders inside the drawers. To find a specific table, you need to know which drawer it lives in. A SELECT * FROM gobeacc is "open the default drawer and look for GOBEACC" — and if your default drawer is SATURN, the file is not there. The folder exists, but in another drawer. You need to either name the drawer explicitly (SELECT * FROM general.gobeacc) or have the filing system set up to look in multiple drawers automatically — Oracle synonyms, the records room's cross-reference index.

Like the records room, knowing which drawer holds what is half the job. The other half is having the keys to open the drawer — Oracle grants, the permissions that say which users can read which schemas.

What it really is

An Oracle schema is a namespace — a collection of tables, views, sequences, and other objects owned by a specific database user. Every table belongs to exactly one schema. The fully-qualified name is SCHEMA.TABLE.

The most common Banner schemas:

  • **SATURN** — the Student system. Most S*-prefixed tables (SPRIDEN, SGBSTDN, SFRSTCR, SCBCRSE, STVTERM, etc.) live here. The biggest schema by both row count and table count.
  • **GENERAL** — cross-system tables. GOBEACC (e-account security), GUBALOG (audit log), GOREMAL (email addresses), GTVNATN (nations), GTVZIPC (ZIP codes). The GOBEACC-in-GENERAL pitfall: GOBEACC's prefix looks like it should be in GENERAL (and it is), but many newcomers assume it lives in SATURN alongside other person tables — and get ORA-00942.
  • **PAYROLL** — payroll tables. PHRHIST (pay history), PEBEMPL (employee base). Some installations place these under different schema names; verify locally.
  • **FIMSMGR** — Finance Management. Finance and General Ledger tables.
  • **TAISMGR** — Accounts Receivable / Tax. TBRACCD (AR transactions), TBBDETC (detail codes).
  • **FINAID** — Financial Aid. RPRAWRD, RPBAWRD, etc. Some installations name this differently.

Synonyms are aliases that map an unqualified name to a fully-qualified one. CREATE PUBLIC SYNONYM gobeacc FOR general.gobeacc lets every user write SELECT * FROM gobeacc and have Oracle automatically resolve to general.gobeacc. Most Banner installations create public synonyms for the most-used tables, which is why SELECT * FROM sgbstdn "just works" — there's a synonym pointing to saturn.sgbstdn.

Grants are permissions. Your database user must have SELECT privileges on a table to read it. Banner ships with standard role-based grants (BAN_DEFAULT_M etc.), and reporting users typically have read access to most tables through these roles. If you get ORA-00942 and the table exists in BSS, the most likely causes are: (1) missing synonym in the current environment, or (2) missing grants for your user.

Banner schemas, cross-schema joins, and synonyms Tables live in schemas; joins can cross them the schema prefix names the owner, not a different kind of table SATURN SPRIDEN SGBSTDN SFRSTCR SCBCRSE GENERAL GOBEACC GUBALOG GOREMAL PAYROLL PHRHIST PEBEMPL FIMSMGR FOBAPPD FTVORGN TAISMGR TBRACCD TBBDETC JOIN PIDM PUBLIC SYNONYM gobeacc -> general.gobeacc synonyms let you write SELECT * FROM gobeacc without the schema prefix
Five labeled boxes representing schemas (SATURN, GENERAL, PAYROLL, FIMSMGR, TAISMGR), each containing chips of the tables that live there; arrows showing cross-schema joins (e.g., SATURN.SPRIDEN ↔ GENERAL.GOBEACC on PIDM); a small synonym icon noting the unqualified name resolves via synonym.

**ALL_TABLES** is the Oracle data dictionary view that lists every table you have access to. SELECT owner, table_name FROM all_tables WHERE table_name = 'GOBEACC' returns the schema. The BSS schema search at bss.peopleworksservices.com is faster and more readable, but ALL_TABLES is the SQL fallback when you are already in the database.

See it — the diagram

Five labeled boxes arranged across the canvas, one per schema: SATURN (largest, filled with SPRIDEN, SGBSTDN, SFRSTCR, SCBCRSE, STVTERM chips), GENERAL (with GOBEACC, GUBALOG, GOREMAL, GTVZIPC chips), PAYROLL (with PHRHIST, PEBEMPL chips), FIMSMGR (Finance), TAISMGR (with TBRACCD, TBBDETC chips). A coral arrow labeled JOIN ON PIDM arcs from SATURN.SPRIDEN to GENERAL.GOBEACC — the cross-schema join that powers security reports. A small icon floating near GOBEACC marks "Resolved via PUBLIC SYNONYM → general.gobeacc." The visual says: the tables don't all live in one bucket; cross-schema joins are normal; synonyms make them practical.

Show me the code

Find which schema owns a table:

-- Oracle's data dictionary — the system catalog.
-- ALL_TABLES lists tables your current user can see.
SELECT owner, table_name
FROM   all_tables
WHERE  table_name = 'GOBEACC';
-- Returns: GENERAL  GOBEACC

Query a table by its fully-qualified name:

-- The schema prefix is the drawer name.
-- This works regardless of synonyms.
SELECT g.gobeacc_userid, g.gobeacc_username
FROM   general.gobeacc g
WHERE  g.gobeacc_status_ind = 'A';

Query the same table via synonym (the usual case):

-- A PUBLIC synonym makes the unqualified name work for everyone.
-- Most Banner installations have these for common tables.
SELECT gobeacc_userid, gobeacc_username
FROM   gobeacc                       -- resolved via synonym
WHERE  gobeacc_status_ind = 'A';

Cross-schema join (when the synonym is missing or for safety):

-- A security-audit query joining SATURN tables to a GENERAL table.
-- Use schema prefix on the GENERAL one for safety.
SELECT s.spriden_id, s.spriden_last_name, g.gobeacc_userid
FROM   saturn.spriden s
JOIN   general.gobeacc g
       ON g.gobeacc_pidm = s.spriden_pidm
WHERE  s.spriden_change_ind IS NULL
  AND  s.spriden_entity_ind = 'P';

The BSS schema search at bss.peopleworksservices.com lets you type any table name and returns its schema, description, and column list. Use it as the primary lookup — it is more reliable than guessing the schema from the table-name prefix (see Reading a Banner Table Name — The Seven-Letter Code for why the prefix alone is not enough).

Where intuition fails
  1. The table-name prefix does NOT always tell you the schema. GOBEACC starts with G (suggesting General), and indeed it lives in GENERAL — but the rule is not universal. Some G-prefixed tables live in SATURN because they predate the schema split. The only reliable lookup is ALL_TABLES.OWNER or the BSS schema search.
  1. Missing synonyms break old reports moving to new environments. A report that works in production (where a PUBLIC SYNONYM exists) breaks in test (where the synonym was never created). The error is ORA-00942 table or view does not exist — same as if the table genuinely didn't exist. Always test reports in the target environment before promoting.
  1. **ORA-00942 does not distinguish "no such table" from "you don't have permission."** Oracle deliberately returns the same error to avoid revealing the existence of tables you can't read. If you know a table exists (via BSS) and your query errors with ORA-00942, the next thing to check is your user's grants on that schema.
  1. Cross-schema joins work but can be slow. Oracle handles them, but the optimizer may not have statistics on tables in schemas it doesn't usually consider together. If a cross-schema query is slow, run EXPLAIN PLAN and look for surprising full-table scans. Sometimes manually pre-filtering one side via a subquery helps.
  1. Schema names sometimes differ by installation. Banner ships with standard schemas, but DBAs can rename them or create custom schemas for institution-specific data. Waubonsee may have a WAUBONSEE or similar schema for custom tables. The BSS schema search covers the institution-specific names alongside the Ellucian-standard ones.
The one-sentence takeaway

Banner's tables are organized into Oracle schemas — SATURN (Student), GENERAL (cross-system), PAYROLL, FIMSMGR (Finance), TAISMGR (AR), FINAID. To query a table from outside its schema, you need the schema prefix (GENERAL.GOBEACC) or a public synonym. ORA-00942 is not the same as "the table does not exist" — it means Oracle cannot find or access the table with the name you gave. Check the BSS schema search first.

← All concepts
Track A · What is it, really

Effective Dating — Why Banner Never Forgets

A student changes majors. Banner does not cross out the old one and write the new one on top. It lays a new row on top of the old one and dates it. If your query does not specify which layer you want, Banner hands you all of them — and your report is silently wrong.

8 min readbannereffective-datingsgbstdnscbcrsenbrjobshistory
The hook

A student changes majors from Biology to Nursing. A course gets re-titled from "Introduction to Computing" to "Foundations of Digital Literacy." An employee gets a raise and a new job title. In a normal database, you would UPDATE the row and move on. Banner does not do that. Banner lays a new row on top of the old one, stamps it with the date the change took effect, and leaves the old row exactly where it was. If your query says SELECT * FROM sgbstdn WHERE pidm = 38201 and stops there, Banner hands you every layer — every major that student ever declared — and your report is silently, arithmetically wrong. "Current" is not a column in Banner. It is a question you must learn to ask.

The everyday analogy

Drive past a road cut on a highway and you can see the rock laid down in layers, oldest at the bottom, newest at the top. Each stratum was deposited at a specific moment in geological time — a volcanic ash fall, a sea floor settling, a river flood plain — and once it solidified, nothing dug it back out. The next event simply laid a new layer on top of the previous one.

A roadside rock cut at golden hour: stratified layers of sedimentary rock in horizontal bands. One mid-stratum is highlighted in coral — 'as of Fall 2022.' Identity is the cliff; history is the layers; 'current' depends on the date you ask.
A roadside rock cut at golden hour: stratified layers of sedimentary rock in horizontal bands. One mid-stratum is highlighted in coral — 'as of Fall 2022.' Identity is the cliff; history is the layers; 'current' depends on the date you ask.

To answer the question "what was the surface of this hillside in 1850?" a geologist does not look at today's topsoil. They count down to the layer whose deposition predates 1850 and was not yet covered by anything younger. The most recent stratum whose date is on or before 1850 — that is the "current as of 1850" surface. The cliff is the identity. The layers are the history. What you call "the surface" depends entirely on the date you ask.

That is exactly how Banner stores history. A student's curriculum in SGBSTDN is a stack of strata — one row per declared major or program, each with SGBSTDN_TERM_CODE_EFF set to the term the change took effect. Course catalogs in SCBCRSE are strata of course definitions — the same course code might carry a different title and credit-hour count in 2024 than it did in

  1. Employee jobs in NBRJOBS are strata of pay rates, titles, and FTE

status. The pattern is the same across every Banner master table that matters: identity is stable (see PIDM — The Number Behind Every Person), description is layered.

The mistake new Banner SQL writers make is treating these tables as if they were flat current-state snapshots. They run SELECT * FROM sgbstdn WHERE pidm = ?, get back every stratum the student ever accumulated, and the report shows duplicated students, inflated headcounts, and majors the student abandoned three years ago. Banner did not lie. The query did not ask "current as of when."

What it really is

Effective dating is Banner's built-in mechanism for versioning descriptive attributes over time. When a value changes — a major, a title, a salary, an advisor assignment — Banner does not overwrite the old row. It inserts a new row with a higher effective-date value and leaves the old row intact.

The effective-date column itself varies by table, and Banner is not consistent about its name. On the student side, SGBSTDN uses SGBSTDN_TERM_CODE_EFF — a six-digit term code like '202610' for Fall 2026. On the catalog side, SCBCRSE uses SCBCRSE_EFF_TERM. On the HR side, NBRJOBS uses NBRJOBS_EFFECTIVE_DATE — an actual DATE column. Advisor assignments in SGRADVR use SGRADVR_TERM_CODE_EFF. Every table names the column differently, but the pattern is identical: a row is valid from its effective date forward, until a newer row with a higher effective date supersedes it.

"Current" is not stored anywhere. There is no IS_CURRENT = 'Y' flag on these tables. You compute "current" at query time by finding the row with the maximum effective date that is less than or equal to your target. For "right now," your target is today's term or today's date. For a historical report, your target is the term or date you are reporting on. The query is the same; only the cutoff value changes.

Effective-dated rows as versions in time Same person - same record - four versions in time pidm = 47281 eff_date = 2019-08-15 Adjunct Instructor pidm = 47281 eff_date = 2021-01-04 Full-Time Faculty pidm = 47281 eff_date = 2023-08-22 Senior Faculty pidm = 47281 eff_date = 2025-08-18 Department Chair CURRENT MAX(eff_date) <= TODAY effective dating appends history instead of overwriting it
One student's SGBSTDN stack: three layered rows for three curriculum changes, each with its effective term. The MAX-effective layer on or before the target term is highlighted — that is the row your query must isolate.

A row's effective date is the date the change took effect in the real world — the term the student actually switched majors, the date the raise took effect. Banner also has _ACTIVITY_DATE columns on most tables, which record when the row was last touched by a form. Those are the audit trail — the date someone typed the change. They are not the effective date. Confusing the two produces reports where a change entered late appears to have happened on the entry date instead of the real-world effective date.

This is the source-side analog of the warehouse's Slowly Changing Dimension Type 2 pattern (see Slowly Changing Dimensions — Keeping History When Attributes Change). Banner is SCD Type 2 on its master tables — it versions by inserting, not by overwriting. The warehouse's job is to mirror that same layered history in its dimension tables, with surrogate keys so that fact rows can point to the correct historical version without a MAX() subquery on every join.

See it — the diagram

The stack diagram shows one student, three curriculum changes, three rows in SGBSTDN. The bottom row — effective term '202010' (Fall 2020) — declares "Biology." The middle row — effective term '202210' (Fall 2022) — switches to "Nursing." The top row — effective term '202410' (Fall 2024) — switches again to "Health Sciences." To ask "what was this student's major in Spring 2023 (term '202320')?" you walk down from the top to the first row whose effective term is ≤ '202320' — the Nursing row. The MAX-effective subquery does exactly that walk. The diagram makes the walk visible.

Show me the code

Here is the mistake the article exists to prevent. A student changed majors three times. This query asks for their curriculum and gets all three layers:

-- WRONG: returns every historical curriculum row for the student.
-- A student who changed majors 3 times appears 3 times in the result.
SELECT s.sgbstdn_pidm,
       s.sgbstdn_majr_code_1,
       s.sgbstdn_term_code_eff
FROM   sgbstdn s
WHERE  s.sgbstdn_pidm = 38201;

Three rows. Three different majors. If this query feeds an enrollment report, the student is counted three times.

The canonical Banner fix is the MAX-effective subquery — find the row whose effective term is the greatest one on or before your target. For the student's current curriculum (target = all terms up to today):

-- RIGHT: the topmost stratum — the student's current curriculum.
-- See [[B3_effective_max]] for a deeper dive on this SQL idiom.
SELECT s.sgbstdn_pidm,
       s.sgbstdn_majr_code_1     AS current_major,
       s.sgbstdn_term_code_eff   AS effective_since
FROM   sgbstdn s
WHERE  s.sgbstdn_pidm = 38201
  AND  s.sgbstdn_term_code_eff = (
       SELECT MAX(s2.sgbstdn_term_code_eff)
       FROM   sgbstdn s2
       WHERE  s2.sgbstdn_pidm = s.sgbstdn_pidm);

One row. The current major only. The subquery finds the highest effective term for this PIDM, and the outer query filters to that single row.

Now ask a historical question — what was this student's major as of Fall 2022?

-- The stratum that was on top as of Fall 2022 (term '202210').
-- Same pattern, with a cutoff on the inner MAX.
SELECT s.sgbstdn_majr_code_1 AS major_as_of_fall_2022
FROM   sgbstdn s
WHERE  s.sgbstdn_pidm = 38201
  AND  s.sgbstdn_term_code_eff = (
       SELECT MAX(s2.sgbstdn_term_code_eff)
       FROM   sgbstdn s2
       WHERE  s2.sgbstdn_pidm = s.sgbstdn_pidm
         AND  s2.sgbstdn_term_code_eff <= '202210');

The inner MAX() is now bounded — it only considers terms up to and including Fall 2022. The outer query returns the Nursing row, because Nursing was the topmost stratum as of that term. Biology is below it; Health Sciences has not yet been deposited. The pattern is identical for NBRJOBS, where the column is a DATE instead of a term code — replace <= '202210' with <= DATE '2022-09-15' and the logic is unchanged.

Where intuition fails

Four traps that catch even experienced SQL writers:

  1. No effective-date filter = duplicates. The most common Banner SQL bug. A

student with three curriculum changes appears three times. Headcounts are inflated. Totals are multiplied. The Banner Semantic Search SQL Explainer flags SGBSTDN queries that lack the MAX-effective pattern. The fix is always the same: add the correlated MAX() subquery on the effective-date column.

  1. "Current major" depends on WHEN you mean. If a report asks "Fall 2022

enrollment by current major," it needs the major that was current in Fall 2022, not the major that is current today. Joining today's curriculum to a historical fact table is silent revisionism — the report looks correct but the labels are from the wrong stratum. The MAX() subquery needs the same cutoff as the report's time window. The Effective-Date Trap — Joining to Yesterday's Row covers this gotcha in full, with a worked audit example.

  1. Term codes are strings, but they sort correctly. Banner term codes use

the format 'YYYYTT' where TT encodes the term within the year — 10 for Fall, 20 for Spring, 30 for Summer at most installations. MAX() on a string column works because the format is lexicographically ordered: a higher year sorts later, and within a year a higher term code sorts later. Do not CAST to integer — some legacy term formats break on numeric conversion, and the string sort has been reliable for decades.

  1. Effective dating versions attributes, not balances. A student's major in

SGBSTDN is effective-dated. The student's cumulative GPA in SHRTRCE is not — it is a transactional table where each row is an event (a term's grades calculated), not a version of a description. Do not apply the MAX-effective pattern to transactional tables expecting "the current balance." The patterns are different, and Track E covers the traps of treating one like the other.

The one-sentence takeaway

Banner versions history by adding new rows with an effective date. The old rows stay. "Current" is not a column — it is a query you must write.

← All concepts
Track A · What is it, really

Argos, X-Rayed — The DataBlock, the Report, the Parameters

Everyone calls it 'a report.' But what you see on screen — the columns, the headers, the dropdowns at the top — is only one of three components layered behind the glass. X-ray the thing, and you see a structure that nobody taught you explicitly: the DataBlock, the Report, and the Parameters. Three subsystems, one device, each invisible to the end user.

7 min readargosdatablockreportparametersanatomyfoundation
The hook

Everyone calls it "a report." But what you see on screen — the columns, the headers, the dropdowns at the top — is only one of three components layered behind the glass. X-ray the thing, and you see a structure that nobody taught you explicitly: the DataBlock, the Report, and the Parameters. Three subsystems, one device, each invisible to the end user.

The everyday analogy

Hold up a modern smartphone and you see a sleek slab of glass and metal. The phone "just works" — you tap an app, the screen updates, you swipe and the page moves. The complexity is hidden behind the case.

Now imagine putting that same phone under a hospital X-ray. The image reveals three distinct subsystems layered behind the glass:

  1. The battery and the motherboard at the back — the load-bearing part where the power and the compute live. Without these, nothing works. The user never sees them, but they are the whole reason the phone functions.
  2. The screen and the speaker at the front — the user-visible output. Everything you read, everything you hear, comes from here. This is the layer the user thinks of AS the phone.
  3. The buttons and the touchscreen sensors — the user-input layer. Volume up/down, the side button, the touchscreen surface. These are how the user controls the phone.
An X-ray of a modern smartphone showing three labeled subsystems: motherboard + battery (DataBlock), screen + speaker (Report), buttons + touchscreen (Parameters); a hospital-style light box illuminating the X-ray from behind.
An X-ray of a modern smartphone showing three labeled subsystems: motherboard + battery (DataBlock), screen + speaker (Report), buttons + touchscreen (Parameters); a hospital-style light box illuminating the X-ray from behind.

Three subsystems, one device, each invisible to the user unless they X-ray it open. The user knows "I press this button, the screen does that" — they don't think about the motherboard pulling data and the speaker driver translating it into sound.

Argos works the same way. From the outside it looks like "a report." But X-ray the report and you see three components:

  • The DataBlock = the motherboard and battery. The SQL that retrieves the data, the parameter declarations that accept user inputs, the column-type metadata. Load-bearing, invisible to the end user.
  • The Report = the screen. The layout the user sees: the columns, the headers, the grouping, the page footers. This is the layer the user calls "the report."
  • The Parameters = the buttons. The dropdowns, edit boxes, date pickers at the top of the Report. The user's controls.

Knowing the X-ray view tells you where to look when something breaks. The SQL is wrong → DataBlock. The columns look bad → Report layout. The user can't enter the right value → Parameter widget config.

What it really is

Argos has three core building blocks. They are interlocked — each feeds into the next — but they are separately configurable, separately testable, and separately breakable.

The DataBlock — the container. This is where the query lives. A DataBlock holds: the SQL query (the load-bearing part — the only part Oracle ever sees); parameter declarations (name, type, widget binding, default value); column-type metadata (text, number, date, with display width and format hints); and an optional named DataBlock identifier (used for :dbn_* cross-references in Argos Parameters — `:main_`, `:lcl_`, `:dbn_`). A DataBlock is the unit of REUSE — one DataBlock can feed multiple Reports (see Shared DataBlocks — One SQL, Many Reports). Change the DataBlock, and every consuming Report changes with it.

The Report — the layout. Consumes a DataBlock's output rows and formats them for the user. A Report holds: the column-display order, widths, and headers; grouping and sub-totalling rules; page header/footer text and images; export format (CSV, PDF, Excel, HTML); and sub-report bindings for banded child sections (each sub-report is itself a Report consuming a child DataBlock).

The Parameters — the user controls. The widgets at the top of the Report. Each Parameter: has a scope (:main_* / :lcl_* / :dbn_*Argos Parameters — `:main_`, `:lcl_`, `:dbn_`); has a widget type (Edit Box, Drop Down, Date, Date Range, Check Box, Multi-Checkbox, Radio Button); has an options query (for dropdowns/multi-checkboxes) that populates the available values; has a declared data type that drives substitution quoting (see How Argos Assembles Your Query — Filters on the WHERE); and has an optional default value and validation rules.

Argos report anatomy Argos report, x-rayed into three layers parameter values flow down into the SQL rows flow up into the layout PARAMETERS v :main_DD_term_code dropdown | :main_EB_subj_code edit-box [] :main_DA_as_of date-picker REPORT CRN Subject Course# Student ID Student Name PDF / CSV / Excel export DATABLOCK SELECT r.sfrstcr_crn, s.spriden_id, ... FROM sfrstcr r JOIN spriden s ON s.spriden_pidm = r.sfrstcr_pidm WHERE r.sfrstcr_term_code = :main_DD_term_code;
Three labeled boxes stacked vertically (DataBlock at the bottom with SQL + parameter declarations, Report in the middle with column layout + grouping, Parameters at the top with widget icons); arrows showing data flow downward (parameter values into DataBlock) and rows flowing upward (DataBlock output into Report).

The lifecycle of running a Report:

  1. User clicks Run.
  2. Argos reads the Parameter widget values the user entered.
  3. Argos substitutes the values into the DataBlock's SQL (using the string-substitution mechanism from How Argos Assembles Your Query — Filters on the WHERE).
  4. The substituted SQL is sent to Oracle.
  5. Oracle returns rows.
  6. The Report's layout formats the rows into the user's chosen output (PDF, Excel, etc.).
  7. The user sees the formatted result.

The same DataBlock can be wired to multiple Reports — a one-DataBlock-to-many-Reports relationship that Shared DataBlocks — One SQL, Many Reports explains in depth via the UNION ALL + discriminator pattern.

See it — the diagram

Three labeled boxes stacked vertically, reading bottom-to-top as data flows: DataBlock at the bottom (containing SQL code, parameter declarations, column-type metadata — coral background), Report in the middle (containing column layout, grouping rules, export formats — ink background), Parameters at the top (containing widget icons: a dropdown, an edit box, a date picker — coral accent). A downward coral arrow from Parameters to DataBlock reads "parameter values flow in via string substitution." An upward amber arrow from DataBlock to Report reads "rows flow out — Oracle result set." The visual is a data-flow diagram that doubles as an anatomy chart: three components, two flows, one report.

Show me the code

A single concrete example — a course-roster Argos object — shown as its three parts.

The DataBlock (the SQL + parameters):

-- DataBlock "CourseRosterByTerm" — holds the SQL and
-- declares two parameters: a term dropdown and an optional
-- subject filter.
SELECT r.sfrstcr_term_code,
       r.sfrstcr_crn,
       r.sfrstcr_subj_code,
       r.sfrstcr_crse_numb,
       s.spriden_id,
       s.spriden_last_name,
       s.spriden_first_name
FROM   sfrstcr r
JOIN   spriden s
       ON  s.spriden_pidm        = r.sfrstcr_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  r.sfrstcr_term_code = :main_DD_term_code
  AND  (r.sfrstcr_subj_code = :main_EB_subj_code
        OR :main_EB_subj_code IS NULL);

-- Parameters declared on this DataBlock:
--   :main_DD_term_code  (text, Drop Down, options from STVTERM)
--   :main_EB_subj_code  (text, Edit Box, optional default = NULL)

The Report (the layout that consumes the DataBlock):

Report "Course Roster"
  DataBlock: CourseRosterByTerm
  Columns shown:
    - Term  (sfrstcr_term_code) - hidden if only one term
    - CRN   (sfrstcr_crn) - width 80
    - Subject (sfrstcr_subj_code) - width 80
    - Course# (sfrstcr_crse_numb) - width 80
    - Student ID (spriden_id) - width 100
    - Student Name (combine last_name + ', ' + first_name) - width 200
  Grouping: by CRN (page break between courses)
  Page Footer: "Page {{page}} of {{pages}}"
  Export: CSV, PDF, Excel

The Parameters (what the user sees at the top of the Report):

Parameter 1: Term (dropdown)
  Widget: Drop Down
  Bound to: :main_DD_term_code
  Options query: SELECT stvterm_code, stvterm_desc
                 FROM stvterm
                 WHERE stvterm_start_date <= SYSDATE + 365
                 ORDER BY stvterm_code DESC

Parameter 2: Subject (optional)
  Widget: Edit Box
  Bound to: :main_EB_subj_code
  Default: (empty)
  Validation: 3-4 letter subject code

The three parts are separate but interlocked — the Parameter flows into the DataBlock's SQL via substitution, the DataBlock produces rows, the Report formats them. Change one, the others stay stable. That stability is the design: you can fix the SQL without touching the layout, or add a Parameter without rewriting the Report.

Where intuition fails
  1. A "broken report" is rarely the Report layout's fault. Most user-reported problems ("the report shows wrong numbers," "I selected a term and got no rows") are DataBlock or Parameter issues, not layout issues. Diagnose in this order: Parameter (did the user's value reach the DataBlock correctly?) → DataBlock (does the SQL return the expected rows in a test query?) → Report (is the layout hiding or grouping rows in a misleading way?).
  1. A DataBlock can be shared across Reports. Changing the DataBlock changes EVERY consuming Report. If you add or remove a column from the DataBlock, every Report that references that column breaks. Use the BSS Argos export feature or the Argos designer's "where is this DataBlock used?" view before editing. See Shared DataBlocks — One SQL, Many Reports.
  1. Sub-reports are full Reports with their own DataBlocks. A banded child section inside a parent Report is itself a complete Report+DataBlock+Parameters structure, just nested. :lcl_* parameters defined on the child are invisible to the parent. See Argos Parameters — `:main_`, `:lcl_`, `:dbn_` for the scope rules.
  1. Parameter options queries are separate from the main SQL. The dropdown population query (e.g., "list every active term") runs at REPORT-OPEN time, not at run time. If a new term is added to STVTERM after the user opens the Report, the new term won't appear in the dropdown until the Report is re-opened. Refresh fixes it.
  1. Report formatting tricks have export-format consequences. A column hidden in the PDF export may still be visible in the CSV export (or vice versa). Page-footer images render in PDF but disappear in Excel. Test each export format independently before declaring the Report done.
The one-sentence takeaway

Every Argos report is three components: the DataBlock (SQL + parameter declarations — the load-bearing layer the user never sees), the Report (layout + formatting — what the user calls "the report"), and the Parameters (widgets — what the user controls). When something breaks, the X-ray tells you where to look: wrong data → DataBlock SQL; ugly layout → Report formatting; user can't enter the right value → Parameter widget config.

← All concepts
Track A · What is it, really

TERM Codes — The Academic Timestamp Banner Uses Everywhere

You see '202610' in every WHERE clause you write. You have used MAX(sgbstdn_term_code_eff) a hundred times. But nobody ever told you why the format was chosen, why it sorts correctly without casting, or what STVTERM actually holds. The term code is not a magic number. It is ISO 8601 adapted to academic time — and the format IS the feature.

7 min readbannerterm-codestvtermsortingfoundationacademic-calendar
The hook

You see '202610' in every WHERE clause you write. You have used MAX(sgbstdn_term_code_eff) a hundred times. But nobody ever told you why the format was chosen, why it sorts correctly without casting, or what STVTERM actually holds beyond its description column. The term code is not a magic number. It is ISO 8601 adapted to academic time — and the format IS the feature.

The everyday analogy

ISO 8601 is the international standard for writing dates: YYYY-MM-DD. The order is not arbitrary. Year first, then month, then day — all fixed-width, all zero-padded. Why? Because that order means a computer can sort dates correctly with plain string comparison. No date library needed. No parsing. No casting.

"2026-09-15" < "2026-10-01" is true. The strings compare lexicographically from left to right. The year prefix matches, so the comparison falls to the month field, where 09 < 10. The format is engineered so the dumbest possible sort produces the right chronological order. A shelf of file folders labeled in ISO 8601 naturally stays in chronological order just by shelving them alphabetically. Nobody has to re-sort the shelf after adding a new folder. The label does the work.

Banner term codes use the same trick, adapted to academic time. The format is YYYYTT — four digits of academic year, two digits of term-within-year. '202610' < '202620' < '202630' < '202710' is true by plain string comparison. Fall 2026 sorts before Spring 2027 because 2026 < 2027 — the year prefix comparison resolves before the term suffix is ever examined. Fall 2026 sorts before Spring 2026 because the year prefixes match and 10 < 20. The MAX() subquery you see in every Effective Dating — Why Banner Never Forgets table can operate on *_term_code_eff columns without ever calling a date function. Banner's designers picked this format on purpose: lexicographic sort equals chronological sort, for free.

A row of ISO-8601-dated file folders on a shelf, sorted naturally because the year-month-day prefix sorts correctly; one folder labeled '202610' shelved among them, showing Banner term codes use the same trick.
A row of ISO-8601-dated file folders on a shelf, sorted naturally because the year-month-day prefix sorts correctly; one folder labeled '202610' shelved among them, showing Banner term codes use the same trick.

The trick has the same payoffs as ISO 8601: indexes work without type tricks, comparisons are universal across databases, there is no ambiguity about whether "10" means October or Fall, and human readers learn the pattern after seeing two examples. It has the same gotcha too: you must respect the format. The moment you CAST a term code to INTEGER, you lose the safety net. The strings are how the system is designed. Trust the strings.

What it really is

A term code is a six-character string in Banner that identifies a specific academic session. The format is YYYYTT:

  • **YYYY** — four-digit academic year. At most institutions, this is the calendar year of the primary Fall term. Fall 2026 is 202610, and the 2026 prefix anchors it to the academic year that runs Fall 2026 through Summer 2027.
  • **TT** — two-digit term-within-year identifier. The convention at most Banner installations is 10 = Fall, 20 = Spring, 30 = Summer. This is convention, not law — always verify against your STVTERM table.

**STVTERM** is the master lookup table. One row per term code. Its key columns:

ColumnWhat it holds
STVTERM_CODEThe YYYYTT string (PK)
STVTERM_DESCHuman-readable description ("Fall 2026")
STVTERM_START_DATECalendar start of the term
STVTERM_END_DATECalendar end of the term
STVTERM_ACYR_CODEAcademic year code — may use its own format, separate from the term code's YYYY prefix
STVTERM_FA_PROC_YRFinancial aid processing year — follows federal FA calendar rules, not the academic calendar

Joining to STVTERM is how you turn a code like '202610' into a human-readable label, a date range, or the correct academic year for reporting.

Lexicographic sort = chronological sort. Because the format places YYYY first and both YYYY and TT are zero-padded and fixed-width, ORDER BY term_code returns terms in correct chronological order. MAX(term_code) returns the latest term. No casting needed. No date library called. This property is what makes the Banner MAX-effective subquery pattern from The MAX() Subquery — Getting the Row That's Current work on *_term_code_eff columns.

Anatomy of Banner term code 202610 Anatomy of term code '202610' the code is compact, but STVTERM is the authority ' 2026 10 ' YYYY academic year anchor TT - season 10=Fall at most installations; verify STVTERM looks up in STVTERM STVTERM row for 202610 STVTERM_CODE '202610' STVTERM_DESC 'Fall 2026' STVTERM_START_DATE 2026-08-24 STVTERM_END_DATE 2026-12-15 STVTERM_ACYR_CODE '202627' * STVTERM_FA_PROC_YR '2627' * * ACYR may use its own format FA_PROC_YR is federal FA year
Anatomy of '202610': the YYYY prefix highlighted as academic year, the TT suffix highlighted as season (10=Fall), plus the STVTERM row that maps the code to its description, start date, end date, and academic year code.

Term codes appear in MANY columns across Banner, each with its own semantic:

  • Effective markers: SGBSTDN_TERM_CODE_EFF, SCBCRSE_EFF_TERM, NBRJOBS_EFF_TERM, SGRADVR_TERM_CODE_EFF — "the term this version took effect."
  • Transaction markers: SFRSTCR_TERM_CODE (registrations), SHRGRDE_TERM_CODE (grades), TBRACCD_TERM_CODE (accounts receivable) — "the term this event belongs to."
  • Admissions pipeline: SARADAP_TERM_CODE — "the term the applicant is applying to."

Each column answers a different question, but the format is universal. The time semantics differ — effective vs. transactional vs. target — but '202610' always means Fall 2026, everywhere.

The term code is the third foundational invariant in Banner. After PIDM (the person key, PIDM — The Number Behind Every Person) and effective dating (the version markers, Effective Dating — Why Banner Never Forgets), term codes are the time axis that every academic transaction lives on.

See it — the diagram

The sorting property is the whole point.

String sorting term codes matches chronological order String sort equals time sort the YYYYTT format makes ordinary ORDER BY line up with time STRING SORT (ORDER BY term_code) '202510' '202520' '202530' '202610' Lexicographic = chronological. The format does the work. Aug 2025 Fall 25 Jan 2026 Spring 26 Jun 2026 Summer 26 Aug 2026 Fall 26 CHRONOLOGICAL ORDER (real-world time)
Four term codes sorted lexicographically left-to-right, with a parallel timeline showing they line up chronologically. The visual payoff: format-first design means string sort equals time sort.

Four codes laid out left to right — '202510', '202520', '202530', '202610' — with a parallel calendar timeline underneath. The string order and the calendar order are identical. This is not a coincidence. The format was chosen so that the string comparison '202530' < '202610' is true for the same reason September 30 comes before October 1 in ISO 8601: the year-month prefix dominates, and 2025 is less than 2026. The TT suffix only matters when the YYYY prefixes match — exactly when it should. The MAX() subquery in The MAX() Subquery — Getting the Row That's Current is the single most common consumer of this property: WHERE sgbstdn_term_code_eff = (SELECT MAX(s2.sgbstdn_term_code_eff) ...) works because MAX() on a VARCHAR column produces the chronologically latest term. The format earns that query its correctness.

Show me the code

**The simplest STVTERM query — turn a code into a label:**

SELECT stvterm_code,
       stvterm_desc,
       stvterm_start_date,
       stvterm_end_date
FROM   stvterm
WHERE  stvterm_code = '202610';

Sort terms chronologically — no casting needed:

-- Lexicographic sort = chronological sort, because of YYYYTT.
-- This is the foundation of every MAX(term_code) subquery.
SELECT stvterm_code, stvterm_desc
FROM   stvterm
WHERE  stvterm_code BETWEEN '202410' AND '202710'
ORDER BY stvterm_code;
-- Returns: 202410 (Fall 2024), 202420 (Spring 2025),
-- 202430 (Summer 2025), 202510 (Fall 2025), ...

Find the current term as of today:

SELECT stvterm_code, stvterm_desc
FROM   stvterm
WHERE  TRUNC(SYSDATE) BETWEEN stvterm_start_date AND stvterm_end_date;

Use a term code as an effective marker — the canonical pattern from The MAX() Subquery — Getting the Row That's Current:

-- Student's current curriculum, using term codes as version markers.
-- This works because MAX() on YYYYTT strings sorts correctly.
SELECT s.sgbstdn_pidm,
       s.sgbstdn_majr_code_1,
       s.sgbstdn_term_code_eff
FROM   sgbstdn s
WHERE  s.sgbstdn_term_code_eff = (
       SELECT MAX(s2.sgbstdn_term_code_eff)
       FROM   sgbstdn s2
       WHERE  s2.sgbstdn_pidm = s.sgbstdn_pidm);
Where intuition fails

Five gotchas — even experienced Banner SQL writers trip on these:

  1. **The TT digits are convention, not law — verify against STVTERM.** The 10/20/30 mapping (Fall/Spring/Summer) is universal at most colleges, but some installations use different digits, and some inherited legacy data uses entirely different formats. Always eyeball STVTERM to confirm. If you write SQL that assumes SUBSTR(term, 5, 2) = '10' means Fall, document that assumption and validate it before shipping the report.
  1. **Do not CAST term codes to INTEGER.** The strings sort correctly without it. Casting to integer defeats any index on the term column and breaks if your installation ever has non-standard 7-character or 8-character term codes in legacy data. The strings are the contract. Trust them.
  1. **STVTERM_ACYR_CODE is NOT the same as the YYYY prefix.** A term code's first four digits identify the term's anchor year, but the academic year code is a separate column with its own format. Some installations use a six-digit YYYYYY academic year (e.g. 202526 for the AY spanning Fall 2025 through Summer 2026). Reports that filter by academic year should join to STVTERM and use STVTERM_ACYR_CODE, not derive the year from the term code's first four digits.
  1. Financial aid uses its own year. STVTERM_FA_PROC_YR is the FA processing year, which follows federal financial-aid calendar rules and can differ from the term's anchor year. Fall 2025 ('202510') is FA year 2526 — not 2025. If you are writing financial aid reports, never derive the FA year from the term code yourself; always read STVTERM_FA_PROC_YR.
  1. "Current term" is not a column — it is a query. Banner has no IS_CURRENT_TERM = 'Y' flag on STVTERM. To find the current term, query STVTERM for the row whose date range includes SYSDATE. During inter-term gaps (between Spring end and Summer start), there may be zero matching rows. Handle "no current term" gracefully, or extend the query to find the nearest upcoming term.
The one-sentence takeaway

Banner term codes are YYYYTT strings engineered so that lexicographic sort equals chronological sort — the same trick ISO 8601 uses. Trust the strings. Join STVTERM for the human-readable label. Never cast to integer.

← All concepts
Track B · The canonical joins

Joining by PIDM — SPRIDEN and the Universal Key

Every report that displays a person's name uses the same three-line SQL incantation. It looks like boilerplate. It is not. Each condition earns its place — and if you move any of them to the wrong clause, you change what the word LEFT means.

5 min readbannerpidmspridenjoincanonicalfoundation
The hook

Every report that displays a person's name uses the same three-line SQL incantation. It looks like boilerplate. It is not. Each condition earns its place — and if you move any of them to the wrong clause, you change what the word LEFT means.

The everyday analogy

Fly into a country and the customs officer holds out a hand for one thing: your passport. The form you filled out on the plane is different in every country — different boxes, different languages, different colors of ink — but the passport is the same. The officer matches your passport number to your arrival record, checks the photo against your face, and waves you through.

The customs office does not look up arriving passengers by name. Names are messy — spellings vary, transliterations differ, married/maiden distinctions. Names are how the passenger thinks of themselves; passport numbers are how governments track them. Every country, every airport, every customs counter joins to the same passport database on the same number — and then displays the current name from that database for the form they need to print.

A customs counter at an international airport, a passport held open on the desk with its number highlighted in coral; behind the officer, a wall of arrival-form templates in different languages, all sharing the same passport-number lookup.
A customs counter at an international airport, a passport held open on the desk with its number highlighted in coral; behind the officer, a wall of arrival-form templates in different languages, all sharing the same passport-number lookup.

Banner is the customs office and PIDM is the passport number. Every person-bearing Banner table — SFRSTCR (the registration form), PHRHIST (the payroll record), NBRJOBS (the job assignment), GOBEACC (the security badge) — holds the PIDM. To put a human-readable name on the report, you join to SPRIDEN (the passport database) on PIDM and pull the current name. The join is identical every time because the contract is identical: same passport, same translation.

A returning citizen presents the same passport as a visiting tourist — the passport database does not care WHY you are entering. A person playing multiple roles in Banner (student + employee + vendor) shows the same PIDM at every counter; the join pattern is unchanged whether the source table is a student record or an employee record. Three conditions, one pattern, every counter.

What it really is

The canonical SPRIDEN join has three conditions, and they all live INSIDE the ON clause:

  1. **s.spriden_pidm = <source>.<col>_pidm** — the actual join key. The _pidm suffix is universal across person-bearing Banner tables: SFRSTCR_PIDM, SGBSTDN_PIDM, PHRHIST_PIDM, NBRJOBS_PIDM, GOBEACC_PIDM, FTVVEND_PIDM.
  2. **s.spriden_change_ind IS NULL** — restrict to the CURRENT name row. SPRIDEN holds one row per name version per person; without this filter the join multiplies rows by every historical name change. See SPRIDEN Without CHANGE_IND — The Duplicate-Name Trap.
  3. **s.spriden_entity_ind = 'P'** — restrict to people, not companies. The PIDM space is shared with corporations ('C'); vendor records can leak into person rosters without this filter.

Why all three belong in ON, not WHERE: with INNER JOIN, filters in WHERE behave the same. But the moment someone changes the JOIN to LEFT JOIN (to include sources without a SPRIDEN row), a WHERE filter rejects the NULL-extended rows and silently converts the LEFT JOIN back to an INNER. See The Phantom INNER JOIN — When a WHERE Breaks Your LEFT JOIN for the trap. Filters that belong to the join go in ON.

Join PIDM-bearing sources through SPRIDEN SFRSTCR pidm term_code crn PHRHIST pidm year gross GOBEACC pidm userid status SPRIDEN pidm id last_name first_name change_ind entity_ind ON source_pidm = spriden_pidm AND change_ind IS NULL AND entity_ind = 'P' ON source_pidm = spriden_pidm AND change_ind IS NULL AND entity_ind = 'P' ON source_pidm = spriden_pidm AND change_ind IS NULL AND entity_ind = 'P' ON pidm = pidm AND change_ind IS NULL AND entity_ind = 'P' - three conditions, every time.
Three source tables (SFRSTCR, PHRHIST, GOBEACC) on the left, each with a _pidm column highlighted; a single SPRIDEN box on the right with spriden_pidm highlighted; three CORAL arrows converging from the source tables to SPRIDEN, each labeled with the 3-condition ON clause.

Common SELECT choices from SPRIDEN: SPRIDEN_ID (the 8-digit visible Banner ID — what users recognize); SPRIDEN_LAST_NAME, SPRIDEN_FIRST_NAME, SPRIDEN_MI (current name components); SPRIDEN_SSN (sensitive — avoid unless audit-required). Never display the raw PIDM to users (see PIDM — The Number Behind Every Person gotcha 5).

When you need TWO different people in the same query (student + advisor, employee + supervisor), the pattern extends to two SPRIDEN joins with different aliases. See The Double SPRIDEN — Naming Two People in One Query.

See it — the diagram

Three source tables on the left — SFRSTCR (registrations), PHRHIST (payroll), GOBEACC (security accounts) — each with their _pidm column highlighted in coral. Three coral arrows converge from those columns to a single SPRIDEN box on the right, its spriden_pidm column highlighted. Each arrow carries the full 3-condition ON clause in small mono type below it. The visual says: three different source tables, three different report types, one PIDM, one SPRIDEN join pattern. The passport analogy made structural: same lookup, every counter.

Show me the code

The canonical join — course roster:

-- Course roster for a specific term: PIDM is the passport,
-- SPRIDEN translates it to a current name.
SELECT s.spriden_id,
       s.spriden_last_name,
       s.spriden_first_name,
       r.sfrstcr_crn,
       r.sfrstcr_credit_hr
FROM   sfrstcr r
JOIN   spriden s
       ON  s.spriden_pidm        = r.sfrstcr_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  r.sfrstcr_term_code = '202610'
ORDER BY s.spriden_last_name, s.spriden_first_name;

Same pattern, different source — employee payroll line:

SELECT s.spriden_id,
       s.spriden_last_name || ', ' || s.spriden_first_name AS name,
       p.phrhist_year,
       p.phrhist_payno,
       p.phrhist_gross
FROM   phrhist p
JOIN   spriden s
       ON  s.spriden_pidm        = p.phrhist_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  p.phrhist_disp = 'P';

Vendor source — individuals vs companies:

SELECT s.spriden_id,
       s.spriden_last_name AS company_or_lastname,
       v.ftvvend_vend_code,
       v.ftvvend_active_ind
FROM   ftvvend v
JOIN   spriden s
       ON  s.spriden_pidm        = v.ftvvend_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  v.ftvvend_active_ind = 'Y';
Where intuition fails
  1. All three conditions in ON, never in WHERE. The single most copied bug. WHERE spriden_change_ind IS NULL works for INNER JOIN but converts a LEFT JOIN back to INNER silently. Put the change_ind and entity_ind filters in ON alongside the PIDM equality.
  1. **Omitting entity_ind = 'P' lets corporations into person rosters.** Most reports never see corporations, but the moment a join chain touches a vendor table, the missing filter shows up as "Acme Office Supplies, Inc." in the student list. The filter is cheap insurance.
  1. **LEFT JOIN spriden when SPRIDEN might be missing.** A source table can hold a PIDM that has been hard-deleted from SPRIDEN (rare but possible in legacy migrations). Inner-joining drops those rows silently. Use LEFT JOIN with COALESCE(spriden_last_name, 'UNKNOWN') if completeness matters.
  1. **Joining by SPRIDEN_ID instead of SPRIDEN_PIDM.** The ID is the visible 8-digit number users recognize; the PIDM is the internal surrogate. The ID can change (corrections, re-issues); the PIDM cannot. Always join on PIDM. See PIDM — The Number Behind Every Person.
  1. **Selecting SPRIDEN_SSN without need.** The Social Security Number is sensitive PII. Including it in a SELECT exposes it to logs, exports, screenshots, and people who should not see it. Default to not selecting it; require an explicit audit-trail justification.
The one-sentence takeaway

The canonical SPRIDEN join is three conditions in ON: spriden_pidm = <source>_pidm AND spriden_change_ind IS NULL AND spriden_entity_ind = 'P'. All three belong in ON, never in WHERE. The pattern is identical across every person-bearing Banner table. One idiom, one pattern, every counter.

← All concepts
Track B · The canonical joins

TERM_CODE + CRN — The Registration Compound Key

You write JOIN ssbsect ON ssbsect_crn = sfrstcr_crn. The query runs. It returns rows — five times more than expected. The CRN looked global. It is not. CRN is unique only WITHIN a term, and you just joined across every term that ever reused it.

5 min readbannerterm-codecrnsfrstcrssbsectcompound-keyjoin
The hook

You write JOIN ssbsect ON ssbsect_crn = sfrstcr_crn. The query runs. It returns rows — five times more than expected. The CRN looked global. It is not. CRN is unique only WITHIN a term, and you just joined across every term that ever reused it.

The everyday analogy

Look at a boarding pass. The big number on it — UA 1234 — is the flight number. Type just UA 1234 into a flight-status app and the app asks "for which date?" Because flight number UA 1234 is run almost every day. The flight from Chicago to Denver on March 15 is a completely different flight from the one on March 16 — different crew, different aircraft, different passengers, different weather, different on-time history. The flight number identifies the ROUTE; the date identifies the SPECIFIC FLIGHT.

A boarding pass on a wooden desk showing flight number 'UA 1234' large in coral, date '2026-03-15' beside it in amber; alongside, a second boarding pass with same flight number 'UA 1234' but different date '2026-03-16' — two different flights, same number.
A boarding pass on a wooden desk showing flight number 'UA 1234' large in coral, date '2026-03-15' beside it in amber; alongside, a second boarding pass with same flight number 'UA 1234' but different date '2026-03-16' — two different flights, same number.

To find your seat, the airline's system needs BOTH: flight number AND date. The seat assignment on UA 1234 / 2026-03-15 is unrelated to the seat assignment on UA 1234 / 2026-03-16. The same is true of every operational record — the catering manifest, the fuel order, the gate assignment, the delay log. All keyed on the compound (flight number, date).

Banner's registration system has the same shape. CRN 12345 is the flight number — a Course Reference Number that identifies a specific section pattern. TERM_CODE '202610' is the date — the academic session that section was offered in. Together they identify one specific section: CRN 12345 in Fall 2026, with its specific instructor, meeting times, enrolled students, and grade roster. CRN 12345 in Spring 2027 is a different section — possibly the same course taught by the same instructor, possibly something entirely unrelated. The CRN is the route; the term is the date; you need both.

What it really is

CRN (SFRSTCR_CRN, SSBSECT_CRN) is a 5-digit number unique WITHIN A TERM but reused ACROSS TERMS. CRN 12345 in Fall 2026 has no relationship to CRN 12345 in Spring 2027 even if they share an instructor or a subject code.

TERM_CODE (SFRSTCR_TERM_CODE, etc.) is the academic session anchor — see TERM Codes — The Academic Timestamp Banner Uses Everywhere for the YYYYTT format.

Together they form the compound primary key of a course section. SSBSECT (the section master) has a composite PK on (ssbsect_term_code, ssbsect_crn). Every joining table — SFRSTCR, SHRGRDE, SSRMEET — references both.

Compound join: term code plus CRN SFRSTCR pidm term_code crn credit_hr SSBSECT term_code crn subj_code crse_numb ON sect.ssbsect_term_code = r.sfrstcr_term_code AND sect.ssbsect_crn = r.sfrstcr_crn CRN alone is NOT global - it is reused across terms. Both conditions are required.
Two table cards (SFRSTCR and SSBSECT) side by side; each card shows two highlighted cells — term_code and crn; two CORAL arrows connect the two pairs of cells between the cards; below, the ON clause with both equality conditions in mono.

The JOIN pattern is two equality conditions in the ON clause: one on _term_code, one on _crn. Same shape every time, just paired across different source tables.

Why CRNs are reused: the registration system has finite CRN space (5 digits = max 99,999) and re-uses CRNs across terms by design. A section that ran in Fall 2020 may have its CRN recycled to a completely different course in Fall 2026. Joining on CRN alone treats these as the same section — wrong.

To pull the section's details (title, meeting times, instructor), the canonical chain is SFRSTCR → SSBSECT → SCBCRSE. See Catalog vs Section — SCBCRSE and SSBSECT for the catalog-vs-section distinction and The MAX() Subquery — Getting the Row That's Current for the SCBCRSE effective-dating pattern.

See it — the diagram

Two table cards side by side — SFRSTCR on the left, SSBSECT on the right. Each card highlights two cells in coral: term_code and crn. Two coral arrows connect the matching pairs: one from sfrstcr_term_code to ssbsect_term_code, one from sfrstcr_crn to ssbsect_crn. Below, the ON clause is written in monospace: ON sect.ssbsect_term_code = r.sfrstcr_term_code AND sect.ssbsect_crn = r.sfrstcr_crn. The visual is the flight-number-plus-date pattern rendered as a SQL join: two columns in the ON, never one.

Show me the code

The simple roster — join SFRSTCR to SSBSECT on the compound key:

-- Roster for a specific section: TERM_CODE + CRN both required.
SELECT s.spriden_id,
       s.spriden_last_name,
       sect.ssbsect_subj_code,
       sect.ssbsect_crse_numb,
       sect.ssbsect_seq_numb,
       r.sfrstcr_credit_hr
FROM   sfrstcr r
JOIN   ssbsect sect
       ON  sect.ssbsect_term_code = r.sfrstcr_term_code
       AND sect.ssbsect_crn       = r.sfrstcr_crn
JOIN   spriden s
       ON  s.spriden_pidm        = r.sfrstcr_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  r.sfrstcr_term_code = '202610'
  AND  r.sfrstcr_crn       = '12345';

The bug — CRN-only join (silent multiplication):

-- WRONG: joins on CRN alone, ignoring term_code.
-- If CRN 12345 has been reused in 3 prior terms, this returns
-- 4x the expected rows.
SELECT r.sfrstcr_pidm, sect.ssbsect_subj_code
FROM   sfrstcr r
JOIN   ssbsect sect ON sect.ssbsect_crn = r.sfrstcr_crn
WHERE  r.sfrstcr_term_code = '202610';

Grade history for a section — same compound join:

SELECT s.spriden_id,
       g.shrgrde_grde_code_final,
       g.shrgrde_credit_hours
FROM   shrgrde g
JOIN   spriden s
       ON  s.spriden_pidm        = g.shrgrde_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  g.shrgrde_term_code = '202610'
  AND  g.shrgrde_crn       = '12345';
Where intuition fails
  1. CRN-alone joins are the single most common multiplication bug in registration reporting. The query "looks" right — one extra condition in WHERE, one fewer condition in ON — but returns 2x, 5x, 10x the rows depending on how often the CRN has been reused. The BSS SQL Explainer flags SSBSECT JOIN ... ON crn without the term_code companion.
  1. Both conditions belong in ON, not WHERE. Same lesson as Joining by PIDM — SPRIDEN and the Universal Key — putting AND ssbsect_term_code = sfrstcr_term_code in WHERE works for INNER JOIN but converts a LEFT JOIN to an effective INNER. See The Phantom INNER JOIN — When a WHERE Breaks Your LEFT JOIN.
  1. CRN format varies by installation. Most Banner sites use 5-digit numeric CRNs. Some sites use 4-digit. Older migrations sometimes have alphanumeric CRNs. The format does not change the compound-key rule — join on both columns regardless.
  1. **SSRMEET (meeting times) has multiple rows per section.** A section meeting MWF 9-10 might have one row per meeting day. Joining SSBSECT to SSRMEET on the (term, CRN) compound key returns multiple meeting-time rows per section — expected. Aggregate or filter if you need one row per section.
  1. Sub-sections (lab + lecture pairings) share a linked-section code. SSBSECT_LINK_IDENT connects a lecture section to its required labs. A query wanting both lecture AND linked labs follows this link in addition to the compound key — a separate, more advanced join pattern.
The one-sentence takeaway

CRN is unique within a term, not across terms. Every join involving CRN must include TERM_CODE as a second condition. The compound key is (term_code, crn) — two columns in ON, never one. CRN-alone joins silently multiply rows by the number of historical reuses.

← All concepts
Track B · The canonical joins

The MAX() Subquery — Getting the Row That's Current

You will write this pattern a hundred times in your Banner career. Four lines of SQL that look like noise the first time you see them, and like the only thing holding the report together every time after. It is the most important SQL idiom in the entire Banner codebase, and once you can read it in your sleep, every effective-dated table in the ERP opens up.

8 min readbannersql-patterneffective-datingcorrelated-subquerysgbstdnscbcrsenbrjobs
The hook

You will write this pattern a hundred times in your Banner career. Four lines of SQL — a self-join alias, a MAX() over an effective-date column, a correlation predicate, and an optional <= bound — that look like noise the first time you see them, and like the only thing holding the report together every time after. It is the single most important SQL idiom in the entire Banner codebase: the correlated subquery on MAX(effective_date). Master it once, and every effective-dated table — SGBSTDN, SCBCRSE, NBRJOBS, SGRADVR — opens up. Skip it, and your reports silently multiply rows and mislabel history. There is no middle ground.

The everyday analogy

Open the Wayback Machine at archive.org. Type a URL. Then pick a date — say, March 15, 2014. The Wayback Machine does not show you today's version of that website. It does not show you the oldest snapshot it has. It looks at every snapshot ever captured of that URL, filters to the ones whose capture date is on or before March 15, 2014, and shows you the most recent one of those. The snapshot that was current as of the date you asked about.

The Wayback Machine calendar: a URL typed in, a date pin stuck on March 15 2014, a vertical stack of dated snapshots behind — the one whose capture date is the most recent on-or-before the pin is highlighted in coral. The snapshot that was current at the moment you asked.
The Wayback Machine calendar: a URL typed in, a date pin stuck on March 15 2014, a vertical stack of dated snapshots behind — the one whose capture date is the most recent on-or-before the pin is highlighted in coral. The snapshot that was current at the moment you asked.

The mechanics inside that query are exactly what your Banner SQL is doing. The URL is the entity — a student's PIDM in SGBSTDN, a course's subject-plus- number in SCBCRSE, an employee's position in NBRJOBS. The snapshots are the rows in those tables — one per effective-date version, stacked like the geological strata from Effective Dating — Why Banner Never Forgets. The capture date on each snapshot is the effective-date column. The "find the most recent snapshot at or before X" operation is the correlated subquery:

outer.eff_column = (
    SELECT MAX(inner.eff_column)
    FROM   same_table inner
    WHERE  inner.entity_columns = outer.entity_columns
      AND  inner.eff_column <= target_date   -- the "as-of" bound
)

You are running a Wayback Machine over rows that look like a flat table. The correlated subquery is doing the entity filter ("only snapshots of THIS URL"), the date bound ("on or before THIS date"), and the aggregation ("the MAX of what remains") all in one go. That is why it looks busier than a normal WHERE clause. It is not noise. It is three operations compressed into four lines.

What it really is

The pattern has four pieces, and you can point to each one and say what it does:

  1. The outer alias — the row you are testing. FROM sgbstdn s — every

row in the table is a candidate. The subquery decides which one survives.

  1. The inner alias — a self-join scoped to ONE entity. FROM sgbstdn s2

— but the WHERE inside the subquery restricts s2 to rows that share the same entity as s. For SGBSTDN the entity is PIDM alone: s2.sgbstdn_pidm = s.sgbstdn_pidm. For NBRJOBS the entity is three columns: (pidm, posn, suff) — an employee can hold multiple positions, each with its own version history. For SCBCRSE the entity is (subj_code, crse_numb). Getting the entity correlation wrong is the most common mistake in this pattern.

  1. **The MAX()** — over the effective column of the scoped inner set. Among

the rows that belong to this entity, which one has the highest effective date? That row is the current one — the top of the stratum stack.

  1. **The <= bound** (optional) — turns "current" into "as of." Without it,

the MAX() returns the latest effective date for the entity, period — the row that is current today. With it — AND s2.eff_column <= target_date — the MAX() only considers rows whose effective date is on or before the target. That is how you answer "what was this student's major in Fall 2022?" instead of "what is this student's major right now?"

The MAX effective-date subquery pattern The MAX(eff_date) pattern has two passes inner: one row per PIDM with the latest date SELECT pidm, MAX(eff_date) FROM nbrjobs GROUP BY pidm pidm max_eff_date 47281 2025-08-18 51002 2024-03-11 62144 2025-08-18 JOIN ON pidm AND eff_date outer: the full current NBRJOBS row pidm eff_date job_title salary_grade 47281 2025-08-18 OK Department Chair F12 51002 2024-03-11 OK Payroll Analyst S08 62144 2025-08-18 OK Financial Aid Lead S10 two passes - same table - one correct row each
Anatomy of the correlated subquery: outer alias, inner alias on the same table, the entity correlation predicates that scope the MAX to one entity, the MAX over the effective column, and the optional <= bound that turns 'current' into 'as-of.'

The pattern is read-time work. Every query that touches an effective-dated table re-computes the MAX(). In a warehouse with SCD Type 2 surrogate keys (see Slowly Changing Dimensions — Keeping History When Attributes Change), this work moves to load time — the fact row stores the surrogate key that was current at the fact's date, and the query does a plain equi-join. The Banner source has no such luxury. You pay the MAX() cost at query time because Banner stores history by stacking rows, not by giving you a pre-resolved current pointer.

See it — the diagram

The anatomy diagram labels each of the four pieces on a real subquery. The outer alias on the left, the inner alias scoped by entity on the right, the MAX() aggregating over the scoped set, and the <= bound slicing the set to a point in time. Once you can point to each piece and name it, the pattern stops looking like magic and starts looking like a tool. Every effective-dated Banner table uses the same tool; only the entity columns and the effective-date column name change.

Show me the code

Here is the pattern on three different tables. Notice the shape is identical; only the columns differ.

**Student curriculum — the SGBSTDN pattern.** Entity is PIDM alone. No as-of bound means "the latest version, period":

-- Current curriculum for every student.
-- Correlation: pidm only. No bound = the most recent version.
SELECT s.sgbstdn_pidm,
       s.sgbstdn_majr_code_1   AS major,
       s.sgbstdn_term_code_eff AS effective_term
FROM   sgbstdn s
WHERE  s.sgbstdn_term_code_eff = (
       SELECT MAX(s2.sgbstdn_term_code_eff)
       FROM   sgbstdn s2
       WHERE  s2.sgbstdn_pidm = s.sgbstdn_pidm);

**Course catalog as it was at registration time — the SCBCRSE pattern.** Entity is (subj_code, crse_numb). The as-of bound is <= sr.sfrstcr_term_code — the catalog row that was current in the term the student registered, not the catalog row that is current today:

-- Course title and credits as they were WHEN THE STUDENT TOOK THE COURSE.
-- Correlation: (subj_code, crse_numb). Bound: <= the registration's term.
SELECT sr.sfrstcr_term_code,
       sr.sfrstcr_crn,
       sc.scbcrse_subj_code,
       sc.scbcrse_crse_numb,
       sc.scbcrse_title,
       sc.scbcrse_credit_hr_low
FROM   sfrstcr sr
JOIN   scbcrse sc
       ON sc.scbcrse_subj_code = sr.sfrstcr_subj_code
      AND sc.scbcrse_crse_numb = sr.sfrstcr_crse_numb
      AND sc.scbcrse_eff_term = (
          SELECT MAX(sc2.scbcrse_eff_term)
          FROM   scbcrse sc2
          WHERE  sc2.scbcrse_subj_code = sc.scbcrse_subj_code
            AND  sc2.scbcrse_crse_numb = sc.scbcrse_crse_numb
            AND  sc2.scbcrse_eff_term <= sr.sfrstcr_term_code);

The <= sr.sfrstcr_term_code bound is the piece that makes this historically correct. Without it, every registration row — Fall 2020, Spring 2022, Summer 2024 — joins to the 2024 catalog row. The course titles silently update to whatever they are today. The Effective-Date Trap — Joining to Yesterday's Row covers exactly this gotcha.

**Employee job — the NBRJOBS pattern.** Entity is (pidm, posn, suff) — one employee can hold multiple positions (primary job, chair stipend, overload), each with its own version history. The bound is <= SYSDATE because the effective column is a DATE, not a term code:

-- Current job assignment per position per employee.
-- Correlation: (pidm, posn, suff). Bound: <= today.
SELECT nj.nbrjobs_pidm,
       nj.nbrjobs_posn        AS position_code,
       nj.nbrjobs_suff        AS suffix,
       nj.nbrjobs_salary      AS current_salary,
       nj.nbrjobs_effective_date
FROM   nbrjobs nj
WHERE  nj.nbrjobs_effective_date = (
       SELECT MAX(nj2.nbrjobs_effective_date)
       FROM   nbrjobs nj2
       WHERE  nj2.nbrjobs_pidm = nj.nbrjobs_pidm
         AND  nj2.nbrjobs_posn = nj.nbrjobs_posn
         AND  nj2.nbrjobs_suff = nj.nbrjobs_suff
         AND  nj2.nbrjobs_effective_date <= SYSDATE);

Three tables, three entity keys, one shape. Learn the shape, and you can apply it to any effective-dated Banner table without looking it up.

Where intuition fails

Five lessons that will save you from the most common Banner SQL disasters:

  1. Wrong correlation columns = wrong row, no error message. For NBRJOBS,

correlating only on pidm — forgetting posn and suff — makes the subquery return the MAX effective date across ALL of that employee's jobs. The result is typically one job row with another job's effective date attached. The salary looks right. The date looks plausible. The data is silently corrupt. Always correlate on the full entity key — every column that defines a distinct version stream.

  1. **< vs <= is a business decision, not a typo.** If a course catalog

change is effective Fall 2022 ('202210') and a student registered in Fall 2022, does the new catalog row apply to that registration? <= says yes; < says no. Most Banner teams use <= — the change effective IN a term applies TO that term — but confirm the business rule with the Registrar's Office before embedding it in every query. Document the choice.

  1. Term codes are strings, and that is fine. MAX() on

SGBSTDN_TERM_CODE_EFF works because 'YYYYTT' sorts lexicographically in the correct chronological order. Do not CAST to integer inside the subquery — the CAST defeats any index on the effective column and the string sort has been reliable for decades. The only edge case is installations that use non-standard term codes; know your own setup.

  1. The correlated subquery can be slow on large tables. On a table with

millions of rows, the nested-loop self-join implicit in the correlated subquery can drag. An index on (entity_columns, eff_column) is critical — for NBRJOBS, that means (nbrjobs_pidm, nbrjobs_posn, nbrjobs_suff, nbrjobs_effective_date). If the query is still slow, rewrite to an analytic function: ROW_NUMBER() OVER (PARTITION BY entity_columns ORDER BY eff_column DESC) with WHERE rn = 1 in an outer query. Both Oracle and PostgreSQL optimize the window-function form better on large row counts.

  1. Duplicate effective dates produce duplicate rows. If two rows for the

same entity share the maximum effective date — a data quality bug that happens in NBRJOBS when a payroll run is botched and re-entered — the MAX() subquery returns both, and your row count is silently inflated. Add a tiebreaker: ROW_NUMBER() OVER (PARTITION BY entity ORDER BY eff DESC, activity_date DESC) with WHERE rn = 1, or fix the source data.

The one-sentence takeaway

Correlate on the full entity key. Take the MAX of the effective-date column. Add a <= bound for as-of queries. That is the whole pattern.

← All concepts
Track B · The canonical joins

The Double SPRIDEN — Naming Two People in One Query

You need a student's name and their advisor's name on the same row. Both live in SPRIDEN. You join SPRIDEN once and try to get both — and Oracle returns the same name twice. The fix is not a different table. The fix is a second alias.

6 min readbannerspridendouble-joinadvisorsupervisoraliassgradvr
The hook

You need a student's name and their advisor's name on the same row. Both live in SPRIDEN. You join SPRIDEN once and try to get both — and Oracle returns the same name twice. The fix is not a different table. The fix is a second alias.

The everyday analogy

Open a wedding invitation. The names appear on the same line: "Margaret Chen and Daniel Park request the honor of your presence." Two people, one invitation, one line of text. To print this invitation, the stationer needed to look up TWO records in the same registry — the bride's record (for her current legal name) and the groom's record (for his). Same registry, two queries, two names typed side by side.

Now imagine the stationer made a mistake: looked up the bride's record TWICE and printed the result. The invitation would read "Margaret Chen and Margaret Chen request..." A visible bug. But in a SQL roster — student name and advisor name displayed side by side — the equivalent bug is silent unless someone notices the names are duplicated.

A calligraphed wedding invitation laid open with bride and groom names visible side by side; a stationer's reference card off to the side noting two lookup IDs (one per name) into the same family registry.
A calligraphed wedding invitation laid open with bride and groom names visible side by side; a stationer's reference card off to the side noting two lookup IDs (one per name) into the same family registry.

The fix is to consult the registry TWICE explicitly, with two different lookups, and label the results so they don't get confused. In SQL terms: join SPRIDEN twice, with two different aliases, one for each PIDM. The bride's lookup is s (student). The groom's lookup is ai (advisor identity). Each gets its own ON clause. Each returns its own name. The query knows which is which because the aliases label them.

The pattern extends to any "two people on one row" report: applicant + recruiter, employee + supervisor, donor + solicitor, vendor + buyer. Same registry, two lookups, two aliases.

What it really is

The recipe: when a query needs two different names from SPRIDEN, write TWO SPRIDEN joins, each with its own alias, each with the full 3-condition ON clause from Joining by PIDM — SPRIDEN and the Universal Key.

Picking aliases: the Banner Lego convention uses a short suffix that hints at the role:

  • s = the "main" person (student, employee, applicant)
  • ai = "advisor identity" (for the second-person lookup)
  • mi = "manager identity" (for supervisor lookups)
  • ri = "recruiter identity" (for applicant-recruiter)

Each alias gets its own ON clause with the same three conditions: pidm equality + change_ind IS NULL + entity_ind = 'P'. The conditions are NOT shared across aliases. Each join is a complete, independent lookup — copy-paste the filter set.

SELECT columns are disambiguated by alias prefix: s.spriden_last_name AS student_lname and ai.spriden_last_name AS advisor_lname. Without the prefix, Oracle errors with "ambiguous column."

Two SPRIDEN aliases from one intermediate table SPRIDEN s pidm last_name first_name SGRADVR sgradvr_pidm sgradvr_advr_pidm sgradvr_term_code_eff sgradvr_prim_ind SPRIDEN ai pidm last_name first_name alias = student alias = advisor identity Two SPRIDEN joins, two aliases, same 3-condition ON clause on each.
Center: an intermediate table (SGRADVR) with two PIDM columns highlighted (sgradvr_pidm and sgradvr_advr_pidm); left and right: two separate SPRIDEN boxes labeled s (student) and ai (advisor identity); coral arrows from each intermediate PIDM column to the matching SPRIDEN box.

The intermediate join that supplies the second PIDM is often SGRADVR (advisor assignment), NBRJOBS (supervisor chain), or SARAPRSP (applicant prospect). Each holds both the main PIDM (in *_pidm) and the second person's PIDM (in *_advr_pidm, *_supv_pidm, etc.).

Primary-advisor filter: SGRADVR_PRIM_IND = 'Y' selects the student's PRIMARY advisor. Without it, the join returns one row per advisor — silently multiplying the result by the number of advisors a student has.

See it — the diagram

An intermediate table (SGRADVR) sits in the center with two PIDM columns highlighted in coral: sgradvr_pidm (the student) and sgradvr_advr_pidm (the advisor). On the left, a SPRIDEN box labeled s (student) with its spriden_pidm highlighted. On the right, a second SPRIDEN box labeled ai (advisor identity) with its spriden_pidm highlighted. A coral arrow arcs from sgradvr_pidm to the left SPRIDEN. A second coral arrow arcs from sgradvr_advr_pidm to the right SPRIDEN. Each arrow carries the 3-condition ON clause. The visual says: two lookups, same table, different aliases — the wedding invitation rendered as a SQL join graph.

Show me the code

Student + Primary Advisor — the canonical double SPRIDEN:

-- Student name + primary advisor name in one row.
-- Two SPRIDEN aliases, each with its own 3-condition ON.
SELECT s.spriden_id           AS student_id,
       s.spriden_last_name    AS student_lname,
       s.spriden_first_name   AS student_fname,
       ai.spriden_last_name   AS advisor_lname,
       ai.spriden_first_name  AS advisor_fname,
       sv.sgradvr_advr_code   AS advisor_role
FROM   sgbstdn sb
JOIN   spriden s                        -- student identity
       ON  s.spriden_pidm        = sb.sgbstdn_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
LEFT JOIN sgradvr sv
       ON  sv.sgradvr_pidm           = sb.sgbstdn_pidm
       AND sv.sgradvr_term_code_eff  = (SELECT MAX(sv2.sgradvr_term_code_eff)
                                        FROM sgradvr sv2
                                        WHERE sv2.sgradvr_pidm = sv.sgradvr_pidm)
       AND sv.sgradvr_prim_ind       = 'Y'
LEFT JOIN spriden ai                    -- advisor identity
       ON  ai.spriden_pidm        = sv.sgradvr_advr_pidm
       AND ai.spriden_change_ind  IS NULL
       AND ai.spriden_entity_ind  = 'P'
WHERE  sb.sgbstdn_term_code_eff = '202610';

Three things to notice:

  1. Two SPRIDEN joins: s (student) and ai (advisor identity).
  2. Each SPRIDEN alias has the full 3-condition ON clause.
  3. LEFT JOIN on sgradvr and spriden ai is deliberate — students without a primary advisor still appear, with advisor columns NULL.

Employee + Supervisor — same pattern:

SELECT s.spriden_id          AS empl_id,
       s.spriden_last_name   AS empl_lname,
       mi.spriden_last_name  AS supv_lname
FROM   nbrjobs j
JOIN   spriden s
       ON  s.spriden_pidm        = j.nbrjobs_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
LEFT JOIN spriden mi
       ON  mi.spriden_pidm        = j.nbrjobs_supervisor_pidm
       AND mi.spriden_change_ind  IS NULL
       AND mi.spriden_entity_ind  = 'P'
WHERE  j.nbrjobs_effective_date = (SELECT MAX(j2.nbrjobs_effective_date)
                                   FROM nbrjobs j2
                                   WHERE j2.nbrjobs_pidm = j.nbrjobs_pidm
                                     AND j2.nbrjobs_posn = j.nbrjobs_posn
                                     AND j2.nbrjobs_effective_date <= SYSDATE);
Where intuition fails
  1. The two ON clauses are independent — copy/paste both filter sets. New writers sometimes write the second SPRIDEN join without the change_ind / entity_ind filters because "they're already on the first one." Wrong — each alias is its own join, each needs its own filter set. Otherwise the advisor or supervisor lookup returns duplicates from historical names.
  1. Forgetting the alias prefix in SELECT creates "ambiguous column" errors. Oracle cannot guess whether spriden_last_name means the student's or the advisor's when both aliases are joined. Always prefix every column with its alias.
  1. **SGRADVR_PRIM_IND = 'Y' is mandatory for "the advisor" reports.** Students can have multiple advisors. Without the primary-indicator filter, the join returns one row per advisor, silently multiplying the result. Same trap as missing change_ind — duplicates that look identical except in the advisor column.
  1. **LEFT JOIN vs INNER JOIN is a business decision.** "Students without an advisor" → LEFT JOIN preserves them with NULL advisor columns. INNER JOIN drops them. Pick the business rule explicitly and document it.
  1. Three or more SPRIDEN joins are possible but rare. A query needing student + advisor + recruiter on one row uses THREE SPRIDEN aliases (s, ai, ri). Pattern scales; just keep the aliases clearly named.
The one-sentence takeaway

When a query needs two different people on one row, join SPRIDEN twice with two different aliases. Each alias gets its own full 3-condition ON clause. The intermediate table (SGRADVR, NBRJOBS) supplies the second PIDM. Use LEFT JOIN when the second person might be missing and filter SGRADVR_PRIM_IND = 'Y' for primary advisors.

← All concepts
Track B · The canonical joins

The Security Audit Join — GURACLS Done Right

An auditor asks: 'Show me everyone who has the STUDENT_RECORDS access class.' The answer lives in a single table — GURACLS. But GURACLS doesn't know anyone's name. It only knows user IDs. To answer the auditor's question, you need a three-table chain, and if you miss the active-account filter, the report includes people who left in 2018.

5 min readbannerguraclsgobeaccgubalogsecurityauditjoin
The hook

An auditor asks: "Show me everyone who has the STUDENT_RECORDS access class." The answer lives in a single table — GURACLS. But GURACLS doesn't know anyone's name. It only knows user IDs. To answer the auditor's question, you need a three-table chain, and if you miss the active-account filter, the report includes people who left in 2018.

The everyday analogy

In a multi-tenant office building, the security desk has a binder. Each page is one employee. Each page lists which floors their keycard opens: Floor 12 (Executive), Floor 8 (HR), Floor 5 (Warehouse). Some pages have one entry, some have a dozen. The binder is alphabetical by employee name.

Now imagine the building auditor walks in and asks: "Show me every employee who has access to Floor 12, the executive floor." The security officer cannot answer from the binder directly — the binder is keyed by employee, not by floor. The officer has to flip through every page, scan each employee's access list, and pull out the names whose lists include Floor 12.

An office security desk binder open to a page listing one employee's keycard access (Floor 12, Floor 8, Floor 5) with a small auditor's note in the margin asking 'who else can access Floor 12?'
An office security desk binder open to a page listing one employee's keycard access (Floor 12, Floor 8, Floor 5) with a small auditor's note in the margin asking 'who else can access Floor 12?'

Banner's security model is the same. GURACLS is the binder — one row per (userid, class) pair. To answer "who has the STUDENT_RECORDS class?" you scan GURACLS for rows where the class_code matches, then JOIN out to identify each user. The binder's USERID column is the entry point, not PIDM — so to get the user's name, you go USERID → GOBEACC_PIDMSPRIDEN_LAST_NAME. Three-table chain to put one auditor's question into a one-page report.

The keycard analogy also captures the audit gotcha: just because a name is in the binder does not mean the person is still employed. Terminated employees stay in the binder until someone removes them. Banner has the same problem: inactive users keep their GURACLS rows until somebody runs the cleanup. Audit reports always join through to an active-account filter to exclude ghosts.

What it really is

**GURACLS** — the central security assignment table. One row per (userid, class_code). The class_code is the role; the userid is the person who holds it. A user with 12 access roles has 12 rows in GURACLS.

**GOBEACC** — the e-account / user-account table. One row per userid, with GOBEACC_PIDM linking back to the person in SPRIDEN. The userid is the security identity; the PIDM is the human identity; GOBEACC maps between them.

The class description typically lives in GTVCLAS or STVCLAS (validation tables) or GUBCLAS (the class definition table). Verify your local installation — the table name varies.

The canonical join chain:

GURACLS (the binder)
  → GOBEACC (userid to PIDM)
  → SPRIDEN (PIDM to current name)
GURACLS
  → class lookup (class_code to description)
Security joins route through GOBEACC GURACLS userid class_code activity_date GOBEACC userid pidm status_ind SPRIDEN pidm id last_name first_name change_ind entity_ind ON userid ON pidm + 3-cond + AND gobeacc_status_ind = 'A' (active accounts only) GURACLS is keyed by userid, not PIDM - route through GOBEACC to reach SPRIDEN.
Left to right: GURACLS box (binder of role assignments) → GOBEACC box (userid to PIDM mapping) → SPRIDEN box (PIDM to current name); arrows labeled with the join conditions; a side note showing the active-status filter at the GOBEACC step.

The active-user filter: GOBEACC_STATUS_IND = 'A' excludes terminated users. Without it, every former employee who once had access still appears.

**GUBALOG** is the audit log — a history of every permission grant and revoke. For "when did this user gain access?" or "who revoked this role?" the join extends to GUBALOG (see Soft Deletes — The Rows That Aren't Really Gone for the AUDIT_ACTION convention).

See it — the diagram

Three boxes in a chain, left to right. GURACLS on the left — the binder, keyed by userid — with two rows visible: (MCHEN, STUDENT_RECORDS) and (DPARK, STUDENT_RECORDS). A coral arrow labeled gobeacc_userid = guracls_userid arcs to the GOBEACC box in the center, which holds the userid-to-PIDM mapping and an active-status filter callout. A second coral arrow arcs from GOBEACC to SPRIDEN on the right, labeled with the 3-condition ON clause. The result at the far right shows two resolved names: "Margaret Chen" and "Daniel Park" — the user IDs translated into human identities.

Show me the code

"Who has the STUDENT_RECORDS access class — with names?":

-- Chain: GURACLS -> GOBEACC -> SPRIDEN, plus active filter.
SELECT g.guracls_userid,
       s.spriden_id,
       s.spriden_last_name,
       s.spriden_first_name,
       g.guracls_class_code,
       g.guracls_activity_date
FROM   guracls g
JOIN   gobeacc a
       ON  a.gobeacc_userid     = g.guracls_userid
       AND a.gobeacc_status_ind = 'A'
JOIN   spriden s
       ON  s.spriden_pidm        = a.gobeacc_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  g.guracls_class_code = 'STUDENT_RECORDS'
ORDER BY s.spriden_last_name, s.spriden_first_name;

Full access list per user (LISTAGG'd):

SELECT a.gobeacc_userid,
       s.spriden_last_name || ', ' || s.spriden_first_name AS name,
       LISTAGG(g.guracls_class_code, ', '
               ON OVERFLOW TRUNCATE '...' WITH COUNT)
         WITHIN GROUP (ORDER BY g.guracls_class_code) AS roles
FROM   gobeacc a
JOIN   spriden s
       ON  s.spriden_pidm        = a.gobeacc_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
LEFT JOIN guracls g
       ON g.guracls_userid = a.gobeacc_userid
WHERE  a.gobeacc_status_ind = 'A'
GROUP BY a.gobeacc_userid, s.spriden_last_name, s.spriden_first_name
ORDER BY a.gobeacc_userid;

The audit-trail question — when was access granted?

SELECT b.gubalog_userid,
       b.gubalog_audit_date,
       b.gubalog_audit_action
FROM   gubalog b
WHERE  b.gubalog_class_code  = 'STUDENT_RECORDS'
  AND  b.gubalog_audit_action <> 'D';
Where intuition fails
  1. GURACLS is keyed by USERID, not PIDM. New writers sometimes try to join GURACLS directly to SPRIDEN on PIDM — no PIDM column exists on GURACLS. Always route through GOBEACC.
  1. **GOBEACC_STATUS_IND = 'A' is the active-user filter.** Without it, every terminated employee from the last decade appears in the audit. The filter is cheap and the report's credibility depends on it.
  1. The class description table name varies. GTVCLAS, STVCLAS, GUBCLAS, or institution-specific. Check the BSS schema search to confirm your local lookup table.
  1. **A user with no GURACLS rows is not "no access" — they may have access via group membership (GUBGRPS / GURGRPS).** Banner's security model has both direct user grants and group-mediated grants. A complete audit checks both.
  1. GUBALOG is append-only — old grants accumulate. The audit log keeps every change forever. For "current access" reports, query GURACLS (the current state), not GUBALOG (the history). Use GUBALOG only for the audit-trail question.
The one-sentence takeaway

The security audit join chains GURACLS → GOBEACC → SPRIDEN. GURACLS is keyed by USERID, not PIDM — always route through GOBEACC. Add gobeacc_status_ind = 'A' to exclude terminated users. For the audit trail of who-granted-what-when, extend to GUBALOG with audit_action <> 'D'.

← All concepts
Track B · The canonical joins

Catalog vs Section — SCBCRSE and SSBSECT

SCBCRSE has a column called eff_term. SSBSECT has a term_code. They look related — so people join them. And when they do, three catalog versions of the same course silently multiply the result by three, and a 2020 transcript retroactively shows the 2024 course title. The join needs a bound, not just an equality.

6 min readbannerscbcrsessbsectcourse-catalogsectionsfrstcrjoin
The hook

SCBCRSE has a column called eff_term. SSBSECT has a term_code. They look related — so people join them. And when they do, three catalog versions of the same course silently multiply the result by three, and a 2020 transcript retroactively shows the 2024 course title. The join needs a bound, not just an equality.

The everyday analogy

Walk into a library and search the catalog for "Pride and Prejudice." The catalog returns one entry — the book's CATALOG record. Title, author, publication year, ISBN, Dewey class. The catalog entry tells you what the book IS. It does not tell you whether the library has the book available right now.

To borrow the book, you need a PHYSICAL COPY. The library may have three copies on the shelf — Copy #1 in good condition, Copy #2 with a torn cover, Copy #3 reserved for reference. Each copy has its own status (available, checked out, reserved, lost), its own due date if checked out, its own physical location. You borrow A COPY, not the catalog entry.

A library: a single catalog card pulled from a wooden card-catalog drawer (the descriptive entry) beside three physical copies of the same book on a nearby shelf (the borrowable instances), each copy with its own status sticker.
A library: a single catalog card pulled from a wooden card-catalog drawer (the descriptive entry) beside three physical copies of the same book on a nearby shelf (the borrowable instances), each copy with its own status sticker.

The catalog entry persists across all the copies. If the library buys a fourth copy next year, it gets the same catalog entry. If a copy is lost or weeded, the catalog entry stays — pointing now to fewer physical copies. The catalog describes the WORK; the copies are the lend-able INSTANCES.

Banner's course schema works the same way. SCBCRSE is the catalog entry — "ENGL 201, Introduction to British Literature, 3 credit hours." Every offering of ENGL 201 across every term shares the same catalog entry. SSBSECT is the physical copy — "ENGL 201 CRN 12345, Fall 2026, MWF 9-10, Smith." Students "borrow" sections (enroll in them), not catalog entries. The catalog tells you what the course IS; the section tells you when and where you can take it.

And like the library catalog, SCBCRSE is effective-dated. If the English department renames ENGL 201 in 2024 from "Introduction to British Literature" to "Foundations of British Literature," the catalog gets a new effective row. Sections offered before 2024 still link back to the older catalog version with the older title — see The Effective-Date Trap — Joining to Yesterday's Row for the join-time bound that makes this work correctly.

What it really is

**SCBCRSE** — the course catalog table.

  • Key: (subj_code, crse_numb, eff_term). Effective-dated.
  • Holds: title, credit hours, course level (UG/GR), subject, department, prerequisites (in SCRPREQ).
  • One LOGICAL course = many catalog rows over time.

**SSBSECT** — the section master table.

  • Key: (term_code, crn). NOT effective-dated.
  • Holds: subject code, course number, section sequence, schedule type, max enrollment, status.
  • One section = one row per (term, CRN). When the term ends, the row stays as history.

The join from section to catalog:

SSBSECT.ssbsect_subj_code = SCBCRSE.scbcrse_subj_code
AND SSBSECT.ssbsect_crse_numb = SCBCRSE.scbcrse_crse_numb
AND SCBCRSE.scbcrse_eff_term = (
      SELECT MAX(scbcrse_eff_term)
      FROM scbcrse sc2
      WHERE sc2.scbcrse_subj_code = SSBSECT.ssbsect_subj_code
        AND sc2.scbcrse_crse_numb = SSBSECT.ssbsect_crse_numb
        AND sc2.scbcrse_eff_term <= SSBSECT.ssbsect_term_code
    )

The <= SSBSECT.ssbsect_term_code bound is the key — catalog version current AS OF the section's term, not today. See The MAX() Subquery — Getting the Row That's Current.

Catalog entry versus section offerings SCBCRSE - the catalog entry (the WORK) SSBSECT - sections (the BORROWABLE COPIES) SCBCRSE row subj_code = 'ENGL' crse_numb = '201' eff_term = '202110' title = 'Introduction to British Literature' credit_hr = 3 term=202110 crn=12345 subj='ENGL' crse='201' seq=001 instructor=Smith term=202210 crn=34567 subj='ENGL' crse='201' seq=001 instructor=Jones term=202310 crn=56789 subj='ENGL' crse='201' seq=001 instructor=Chen One catalog entry, many sections; students enroll in sections, not in the catalog.
Left: one SCBCRSE row showing (subj_code='ENGL', crse_numb='201', eff_term='202110', title='Introduction to British Literature') in coral; right: three SSBSECT rows for ENGL 201 in three different terms (each with its own CRN), all joining back to the same catalog entry via subj_code + crse_numb.

Companion tables: SIRASGN (instructor per section), SSRMEET (meeting times), SSRXLST (cross-listed sections).

Why students enroll in sections, not catalog entries: the catalog has no schedule, no instructor, no max enrollment, no CRN. Registration needs a specific time-and-place. The section is the lend-able copy.

See it — the diagram

One SCBCRSE row on the left, showing (subj_code='ENGL', crse_numb='201', eff_term='202110', title='Introduction to British Literature') — the catalog card, rendered as a database row, highlighted in coral. Three coral arrows arc from it to three SSBSECT rows on the right: ENGL 201 CRN 12345 in term 202210, ENGL 201 CRN 23456 in term 202310, ENGL 201 CRN 34567 in term 202410. All three sections point back to the SAME catalog entry via subj_code and crse_numb. A callout below reads "MAX-effective bound on scbcrse_eff_term <= ssbsect_term_code" — the piece that selects the right catalog version for each section's term. The visual says: one catalog entry, many sections; the join is on the course identity, but scoped by term.

Show me the code

A student's roster with course titles, joining all three tables:

SELECT r.sfrstcr_term_code,
       sect.ssbsect_crn,
       sect.ssbsect_subj_code,
       sect.ssbsect_crse_numb,
       sect.ssbsect_seq_numb,
       cat.scbcrse_title         AS course_title,
       cat.scbcrse_credit_hr_low AS credit_hours,
       r.sfrstcr_credit_hr       AS credit_hours_registered
FROM   sfrstcr r
JOIN   ssbsect sect
       ON  sect.ssbsect_term_code = r.sfrstcr_term_code
       AND sect.ssbsect_crn       = r.sfrstcr_crn
JOIN   scbcrse cat
       ON  cat.scbcrse_subj_code = sect.ssbsect_subj_code
       AND cat.scbcrse_crse_numb = sect.ssbsect_crse_numb
       AND cat.scbcrse_eff_term  = (
           SELECT MAX(c2.scbcrse_eff_term)
           FROM   scbcrse c2
           WHERE  c2.scbcrse_subj_code = cat.scbcrse_subj_code
             AND  c2.scbcrse_crse_numb = cat.scbcrse_crse_numb
             AND  c2.scbcrse_eff_term <= sect.ssbsect_term_code)
WHERE  r.sfrstcr_pidm      = 38201
  AND  r.sfrstcr_term_code = '202610';

The bug — joining straight to today's SCBCRSE (silent revisionism):

-- WRONG: no MAX-effective bound on SCBCRSE.
-- Returns TODAY's catalog title for every historical registration.
-- A course retitled in 2024 silently appears with the new title
-- in a 2020 transcript.
SELECT r.sfrstcr_term_code, r.sfrstcr_crn, cat.scbcrse_title
FROM   sfrstcr r
JOIN   ssbsect sect
       ON sect.ssbsect_term_code = r.sfrstcr_term_code
      AND sect.ssbsect_crn       = r.sfrstcr_crn
JOIN   scbcrse cat
       ON cat.scbcrse_subj_code = sect.ssbsect_subj_code
      AND cat.scbcrse_crse_numb = sect.ssbsect_crse_numb;
-- bug: no eff_term bound — all 3 catalog versions match every section
Where intuition fails
  1. The MAX-effective bound on SCBCRSE is mandatory. Without <= sect.ssbsect_term_code, every section joins to every catalog version of that course. Three catalog versions = 3x the rows — silent multiplication AND silent revisionism. See The Effective-Date Trap — Joining to Yesterday's Row.
  1. **SSBSECT_SUBJ_CODE and SCBCRSE_SUBJ_CODE are the same vocabulary but separate columns.** The join condition needs BOTH subj_code AND crse_numb — the catalog is keyed on the (subject, course number) pair, not on either alone.
  1. **Section status (SSBSECT_SSTS_CODE)** flags inactive sections (cancelled, hidden, lab-only). A roster query that includes cancelled sections inflates totals. Filter ssbsect_ssts_code = 'A' (or your local "active" code) when "what is currently being offered" is the question.
  1. **Cross-listed sections (SSRXLST)** are one logical class taught under multiple CRNs. Naive headcount queries double-count cross-listed enrollments. Recognize the cross-list via SSRXLST_XLST_GROUP and pick one representative CRN.
  1. **SCBCRSE_CREDIT_HR_LOW vs SCBCRSE_CREDIT_HR_HIGH** — variable-credit courses have a low/high range. The student's actual credits are in SFRSTCR_CREDIT_HR, not in the catalog. Use SFRSTCR_CREDIT_HR for registration credit totals; use the SCBCRSE range only as a sanity bound.
The one-sentence takeaway

SCBCRSE is the course catalog (what the course IS — title, credits, subject). SSBSECT is the section master (a specific OFFERING — CRN, term, instructor). Students enroll in sections, not catalog entries. Join SSBSECT to SCBCRSE on (subj_code, crse_numb) with a MAX-effective bound of scbcrse_eff_term <= ssbsect_term_code to get the catalog version current AS OF the section's term.

← All concepts
Track C · From generic SQL to Banner

Banner Runs on Oracle — The Dialect You Will Meet

SQL is a standard. Oracle's version of it has its own vocabulary — small differences scattered through every query, none hard, none avoidable. You can't read Banner SQL for ten minutes without meeting SYSDATE, NVL, DUAL, ||, ROWNUM, and DECODE. Learn them once, and the dialect becomes the language.

5 min readoraclebannersql-dialectsysdatedualrownumnvldecode
The hook

SQL is a standard. Oracle's version of it has its own vocabulary — small differences scattered through every query, none hard, none avoidable. You can't read Banner SQL for ten minutes without meeting SYSDATE, NVL, DUAL, ||, ROWNUM, and DECODE. Learn them once, and the dialect becomes the language.

The everyday analogy

An American spends a week in London and notices small differences in the same language. The lift, not the elevator. The lorry, not the truck. The biscuit (sweet, like a cookie), not the biscuit (savory, like small bread). The car park, not the parking lot. Everything is mostly the same — grammar, spelling of common words, conversational patterns — but the small differences are everywhere, and not knowing them produces small puzzlements at every turn.

A phrasebook open on a desk with two columns: 'American English' (lift, lorry, biscuit) and 'British English' (elevator, truck, cookie); alongside, a second phrasebook for SQL: 'generic SQL' (NOW(), TOP, ISNULL) vs 'Oracle SQL' (SYSDATE, ROWNUM, NVL).
A phrasebook open on a desk with two columns: 'American English' (lift, lorry, biscuit) and 'British English' (elevator, truck, cookie); alongside, a second phrasebook for SQL: 'generic SQL' (NOW(), TOP, ISNULL) vs 'Oracle SQL' (SYSDATE, ROWNUM, NVL).

After a week the American has internalized the map. Lift = elevator. Lorry = truck. Take the lift to the second floor (which an American would call the third floor). The dialect becomes natural. The language was never unintelligible; it was just unfamiliar.

Oracle's SQL is the same kind of dialect. Most of the SELECT/FROM/WHERE/GROUP BY skeleton is identical to any other dialect. But scattered through every Oracle query are small idioms: SYSDATE (not NOW()), || (not + for concatenation), NVL (not ISNULL), DECODE (Oracle's CASE before CASE existed), ROWNUM (Oracle's TOP/LIMIT), the magical DUAL table. Each idiom is small. Together they make Oracle code look unmistakably Oracle.

Banner runs on Oracle. Every Banner report, every Argos DataBlock, every Banner Lego recipe in BSS is written in Oracle's dialect. Learn the idioms once, and the dialect becomes the language.

What it really is

Ten Oracle idioms a Banner writer meets every day:

  • **SYSDATE** — the current date and time. Used everywhere Banner needs "now": WHERE x.eff_date <= SYSDATE.
  • **DUAL** — a one-row, one-column system table you SELECT FROM when you need a result without a real source: SELECT SYSDATE FROM dual. Other dialects let you SELECT without a FROM; Oracle requires DUAL.
  • **ROWNUM** — a pseudo-column that numbers rows as they are produced. Used for "first N rows" via WHERE ROWNUM <= N. Modern Oracle (12c+) also supports FETCH FIRST N ROWS ONLY; older Banner code uses ROWNUM. Cannot be used with ORDER BY in the same WHERE without a subquery.
  • **NVL(a, b)** — returns a if non-null, else b. Oracle also supports the standard COALESCE for more than two arguments.
  • **DECODE(expr, v1, r1, v2, r2, ..., default)** — Oracle's pre-CASE conditional. Older Banner SQL uses DECODE; newer uses CASE.
  • **|| (string concatenation)** — Oracle uses ||, not + (SQL Server): last_name || ', ' || first_name.
  • **TO_DATE, TO_CHAR, TO_NUMBER** — explicit type conversion. TO_DATE('2026-09-15', 'YYYY-MM-DD') parses a string to a date. TO_CHAR(SYSDATE, 'YYYY-MM-DD') formats a date back to a string. Format strings are Oracle-specific (uppercase YYYY, MM, DD).
  • **ADD_MONTHS(date, n) and MONTHS_BETWEEN(d1, d2)** — date arithmetic. ADD_MONTHS handles end-of-month wraparound. MONTHS_BETWEEN returns a fractional float (larger date first).
  • **INSTR(haystack, needle) and SUBSTR(s, start, length)** — 1-indexed string operations. INSTR returns 0 if not found. Both named differently from other dialects.
  • **TRUNC(date)** — drops the time portion, leaving midnight. WHERE TRUNC(activity_date) = DATE '2026-09-15'. Also works for numbers: TRUNC(123.456, 1) = 123.4.

Brief mention, deferred: the trailing (+) for outer joins — Oracle's legacy syntax covered in From (+) to ANSI — Retiring Oracle's Old Outer Join.

Oracle equivalents for common SQL idioms Generic SQL / Other Dialect Oracle Equivalent NOW() SYSDATE ISNULL(a,b) NVL(a,b) + (string concat) || TOP 10 WHERE ROWNUM <= 10 GETDATE() SYSDATE LEN(s) LENGTH(s) CONVERT(t, expr) TO_CHAR / TO_NUMBER / TO_DATE SELECT 1 (no FROM) SELECT 1 FROM dual Most Oracle migrations start with these mechanical substitutions.
A side-by-side comparison table: left column 'generic SQL / other dialects', right column 'Oracle equivalent', with ~8 rows showing the most common substitutions (NOW→SYSDATE, ISNULL→NVL, +→||, TOP→ROWNUM, GETDATE→SYSDATE, LEN→LENGTH, CONVERT→TO_CHAR, dual not needed→FROM dual).
See it — the diagram

A side-by-side comparison table: left column "generic SQL / other dialects," right column "Oracle equivalent." Eight rows: NOW()SYSDATE, ISNULLNVL, + for concat → ||, TOP 10WHERE ROWNUM <= 10, GETDATE()SYSDATE, LEN()LENGTH(), CONVERT(varchar, ...)TO_CHAR(...), SELECT expr (no FROM) → SELECT expr FROM dual. Each row links the familiar to the Oracle. The visual is the British/American phrasebook rendered as a SQL reference card — same language, different vocabulary, one-to-one once you have the mapping.

Show me the code

A typical Banner-flavored query — six idioms in one place:

-- SYSDATE, NVL, ||, TO_CHAR, TRUNC, ADD_MONTHS, ROWNUM
SELECT ROWNUM                                  AS line,
       s.spriden_id,
       s.spriden_last_name || ', ' ||
         NVL(s.spriden_first_name, '(no first)') AS full_name,
       TO_CHAR(p.phrhist_year, 'FM9999')       AS fy,
       p.phrhist_gross,
       TRUNC(p.phrhist_activity_date)          AS last_touched
FROM   phrhist p
JOIN   spriden s
       ON  s.spriden_pidm        = p.phrhist_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  p.phrhist_disp = 'P'
  AND  p.phrhist_activity_date >= ADD_MONTHS(SYSDATE, -12)
  AND  ROWNUM <= 100
ORDER BY p.phrhist_activity_date DESC;

DUAL — the one-row table for expression evaluation:

SELECT SYSDATE, USER, 1 + 1 FROM dual;
-- Returns one row: current date, current user, the number 2.

DECODE vs CASE for the same logic:

-- DECODE (older Banner style):
SELECT DECODE(stvterm_code,
              '202610', 'Fall 2026',
              '202620', 'Spring 2027',
              '202630', 'Summer 2027',
              'Other') AS term_label
FROM stvterm;

-- Modern CASE equivalent:
SELECT CASE stvterm_code
         WHEN '202610' THEN 'Fall 2026'
         WHEN '202620' THEN 'Spring 2027'
         WHEN '202630' THEN 'Summer 2027'
         ELSE 'Other'
       END AS term_label
FROM stvterm;
Where intuition fails
  1. **ROWNUM is applied BEFORE ORDER BY.** WHERE ROWNUM <= 10 ORDER BY x returns the first 10 rows the optimizer happens to produce, then sorts them — NOT the top 10 by x. To get "top 10 by x" wrap the query in a subquery and apply ROWNUM outside, or use FETCH FIRST 10 ROWS ONLY.
  1. **NULL || 'something' behaves unexpectedly.** Oracle treats NULL as empty string in concatenation (producing just 'something'), but other functions on the result may still return NULL. Explicit NVL is safer.
  1. **'' (empty string) IS NULL in Oracle.** WHERE x = '' returns no rows because empty string is treated as NULL and NULL is never equal to anything. Use WHERE x IS NULL. This bites hard when migrating to PostgreSQL where '' and NULL are distinct — see From Oracle to PostgreSQL — the Banner SaaS Migration.
  1. DUAL is special — don't query it for production data. Some installations have customized DUAL or security restrictions on it. Use DUAL only for evaluating expressions or constants.
  1. **Date format strings use uppercase tokens (YYYY, MM, DD).** Lowercase tokens like mm may parse differently or fail. Oracle's date format is well-documented but Oracle-specific — port to another database and the format strings need adjustment.
The one-sentence takeaway

Oracle's SQL dialect differs from generic SQL in ~10 everyday idioms: SYSDATE, DUAL, ROWNUM, NVL, DECODE, ||, TO_DATE/TO_CHAR/TO_NUMBER, ADD_MONTHS/MONTHS_BETWEEN, INSTR/SUBSTR, and TRUNC(date). Learn the ten, and Banner SQL becomes readable.

← All concepts
Track C · From generic SQL to Banner

From SQL Server to Oracle — Translating Your Instincts

You know how to write SQL. You've written hundreds of queries on SQL Server. Then you open a Banner DataBlock and see SYSDATE, NVL, ROWNUM, DUAL, || — and every instinct you have about what to type is a half-second wrong. The skill carries. The syntax doesn't. Here is the translation.

5 min readoraclesql-serverdialect-translationsysdatenvlrownumidentifiers
The hook

You know how to write SQL. You've written hundreds of queries on SQL Server. Then you open a Banner DataBlock and see SYSDATE, NVL, ROWNUM, DUAL, || — and every instinct you have about what to type is a half-second wrong. The skill carries. The syntax doesn't. Here is the translation.

The everyday analogy

An American driver flies to London and rents a car at Heathrow. The car looks familiar — steering wheel, pedals, gear shift, windshield wipers. But the steering wheel is on the RIGHT side. The driver is on the LEFT side of the road. The gear shift is operated with the LEFT hand. The turn signal lever is on the OPPOSITE side of the steering column from where the wipers are at home.

The skill of driving translates perfectly. The driver knows how to brake, accelerate, signal, merge, parallel-park. But every motor pattern is mirrored. The first 30 minutes are exhausting — the driver consciously thinks about each action. After a day the new pattern starts to feel natural. After a week the driver is reaching for the gear shift with the correct hand without thinking.

View through a rental-car windshield showing the steering wheel on the right side of the cabin, the road ahead with left-side traffic; a small open guidebook on the passenger seat with translation notes.
View through a rental-car windshield showing the steering wheel on the right side of the cabin, the road ahead with left-side traffic; a small open guidebook on the passenger seat with translation notes.

Moving from SQL Server to Oracle is the same kind of re-mapping. The SQL skill carries — joins, aggregations, window functions, subqueries all work the same way. But the syntax is mirrored everywhere. The instinct to type GETDATE() must be redirected to SYSDATE. The instinct to write TOP 10 col must be redirected to WHERE ROWNUM <= 10. Square brackets for quoted identifiers must be unlearned in favor of double quotes. The driver's skill is intact; the muscle memory needs re-training.

And like driving on the other side of the road, a few patterns will not translate by simple substitution — they have actually-different semantics. The empty string equals NULL in Oracle but not in SQL Server. Identity columns work fundamentally differently. Those are the gotchas that bite even after the rest of the muscle memory has been retrained.

What it really is

The translation table, by category:

Date/time functions: GETDATE()SYSDATE. DATEADD(month, n, dt)ADD_MONTHS(dt, n). DATEDIFF(month, d1, d2)MONTHS_BETWEEN(d2, d1) — note the argument order is REVERSED AND the return type is float in Oracle, integer in SQL Server. GETUTCDATE()SYS_EXTRACT_UTC(SYSTIMESTAMP).

NULL handling: ISNULL(a, b)NVL(a, b). COALESCE(a, b, c) is the SAME in both databases.

String operations: + for concat → ||. LEN(s)LENGTH(s). CHARINDEX(needle, haystack)INSTR(haystack, needle) (argument order reversed!). SUBSTRING(s, start, len)SUBSTR(s, start, len).

Result limiting: SELECT TOP 10 * → wrap in subquery with WHERE ROWNUM <= 10 OR use FETCH FIRST 10 ROWS ONLY (12c+).

Conversion: CONVERT(varchar, date_col, 23)TO_CHAR(date_col, 'YYYY-MM-DD'). CAST(s AS INT)TO_NUMBER(s) or CAST(s AS NUMBER).

Identifiers: [Square Brackets]"Double Quotes".

Control flow: IIF(cond, a, b)CASE WHEN cond THEN a ELSE b END.

SELECT without FROM: SQL Server allows SELECT GETDATE(). Oracle requires SELECT SYSDATE FROM dual.

SQL Server to Oracle translation table Category SQL Server Oracle current date/time GETDATE() SYSDATE add months DATEADD(month, n, d) ADD_MONTHS(d, n) year part DATEPART(year, d) EXTRACT(YEAR FROM d) null replacement ISNULL(a, b) NVL(a, b) concat a + b a || b length LEN(s) LENGTH(s) find substring CHARINDEX(needle, hay) INSTR(hay, needle) top N rows SELECT TOP 10 * WHERE ROWNUM <= 10 cast to string CONVERT(varchar, d, 23) TO_CHAR(d, 'YYYY-MM-DD') quoted name [Square Brackets] "Double Quotes" Most are mechanical - see C3 for semantic-difference gotchas.
Three-column comparison: category | SQL Server | Oracle. Rows for date functions (GETDATE/SYSDATE, DATEADD/ADD_MONTHS), NULL handling (ISNULL/NVL), string ops (+ vs ||, LEN/LENGTH, CHARINDEX/INSTR), result limiting (TOP/ROWNUM), conversion (CONVERT/TO_CHAR), identifiers ([brackets]/"quotes").

Identity / auto-increment: SQL Server's IDENTITY(1,1) is column metadata. Oracle uses a SEQUENCE plus a BEFORE INSERT trigger (pre-12c) or GENERATED ALWAYS AS IDENTITY (12c+). Banner overwhelmingly uses the sequence+trigger pattern — look at the trigger code when reading DDL.

See it — the diagram

A three-column reference card: "Category," "SQL Server," "Oracle." Rows grouped by category. Date functions: GETDATE() / SYSDATE, DATEADD(month,...) / ADD_MONTHS(...), DATEDIFF(month,...) / MONTHS_BETWEEN(...). NULL handling: ISNULL / NVL, COALESCE / COALESCE (same). String ops: + / ||, LEN / LENGTH, CHARINDEX(x,y) / INSTR(y,x) (reversed). Result limiting: TOP 10 / WHERE ROWNUM <= 10 or FETCH FIRST. Conversion: CONVERT(varchar,...) / TO_CHAR(...). Identifiers: [brackets] / "quotes". The visual is the driver's guidebook on the passenger seat, rendered as a SQL reference card.

Show me the code

SQL Server version:

-- SQL Server: top 10 most recent payroll postings.
SELECT TOP 10
       LEN(s.spriden_last_name)              AS lname_length,
       ISNULL(s.spriden_first_name, '(blank)') AS first_name,
       p.phrhist_gross,
       CONVERT(varchar(10), p.phrhist_activity_date, 23) AS posted_dt
FROM   phrhist p
JOIN   spriden s ON s.spriden_pidm = p.phrhist_pidm
WHERE  p.phrhist_disp = 'P'
  AND  p.phrhist_activity_date >= DATEADD(month, -12, GETDATE())
ORDER BY p.phrhist_activity_date DESC;

Oracle (Banner) translation:

-- Oracle: same intent, dialect-translated. ROWNUM wrapped in
-- a subquery to honor the ORDER BY.
SELECT * FROM (
  SELECT LENGTH(s.spriden_last_name)              AS lname_length,
         NVL(s.spriden_first_name, '(blank)')      AS first_name,
         p.phrhist_gross,
         TO_CHAR(p.phrhist_activity_date,
                 'YYYY-MM-DD')                    AS posted_dt
  FROM   phrhist p
  JOIN   spriden s
         ON  s.spriden_pidm        = p.phrhist_pidm
         AND s.spriden_change_ind  IS NULL
         AND s.spriden_entity_ind  = 'P'
  WHERE  p.phrhist_disp = 'P'
    AND  p.phrhist_activity_date >= ADD_MONTHS(SYSDATE, -12)
  ORDER BY p.phrhist_activity_date DESC
) WHERE ROWNUM <= 10;

Substitutions: LEN→LENGTH, ISNULL→NVL, CONVERT→TO_CHAR, DATEADD→ADD_MONTHS, GETDATE→SYSDATE, TOP 10→ROWNUM-in-subquery.

Where intuition fails
  1. **'' = NULL in Oracle but NOT in SQL Server.** A SQL Server query that used WHERE x = '' to find blank rows works fine. The same query in Oracle returns NO rows because Oracle treats '' as NULL and NULL is never equal to anything. Translate to WHERE x IS NULL. See From Oracle to PostgreSQL — the Banner SaaS Migration — PostgreSQL behaves like SQL Server here.
  1. **ROWNUM is applied BEFORE ORDER BY.** SQL Server's SELECT TOP 10 ... ORDER BY x does the right thing. Oracle's WHERE ROWNUM <= 10 ... ORDER BY x returns the first 10 the optimizer scans and THEN sorts them. Wrap in a subquery, or use FETCH FIRST 10 ROWS ONLY.
  1. **DATEDIFF vs MONTHS_BETWEEN have reversed argument order AND different return types.** DATEDIFF(month, '2024-01-01', '2024-06-01') returns integer 5. MONTHS_BETWEEN(DATE '2024-06-01', DATE '2024-01-01') returns float 5.0 — with the larger date FIRST. Argument order is the most common mistake.
  1. Identity columns require a different mental model. SQL Server's IDENTITY(1,1) is column-level metadata. Oracle's sequence+trigger pattern is two separate objects, and the trigger must be present for INSERTs to populate the column. When reading Banner DDL, look at the trigger code — it is not in the column definition.
  1. Stored procedure syntax is entirely different. SQL Server's T-SQL and Oracle's PL/SQL share almost no syntax. Translation here is a rewrite, not a substitution. Banner ships thousands of PL/SQL procedures; reading them requires a different mental model.
The one-sentence takeaway

Moving from SQL Server to Oracle is remapping muscle memory: GETDATE()SYSDATE, TOP 10ROWNUM/FETCH FIRST, ISNULLNVL, +||, LENLENGTH, CONVERTTO_CHAR, DATEADDADD_MONTHS, CHARINDEXINSTR. Most substitutions are 1:1. The non-1:1 mines: '' = NULL in Oracle, ROWNUM before ORDER BY, and reversed DATEDIFF/MONTHS_BETWEEN argument order.

← All concepts
Track C · From generic SQL to Banner

From Oracle to PostgreSQL — the Banner SaaS Migration

Ellucian's cloud Banner targets PostgreSQL, not Oracle. Every Argos DataBlock you write today in Oracle SQL will eventually run against a PostgreSQL database. Some of the SQL translates mechanically. Some doesn't. And one difference — '' = NULL — will silently change what rows your query returns without raising an error.

5 min readoraclepostgresqldialect-translationbanner-saasmigrationempty-string-null
The hook

Ellucian's cloud Banner targets PostgreSQL, not Oracle. Every Argos DataBlock you write today in Oracle SQL will eventually run against a PostgreSQL database. Some of the SQL translates mechanically. Some doesn't. And one difference — '' = NULL — will silently change what rows your query returns without raising an error.

The everyday analogy

An American moves to Spain for a new job. Their professional skills carry — they are still a competent project manager, still able to read a budget, still able to lead a meeting. But the daily life around their work changes in dozens of ways. The currency is euros, not dollars — a clean substitution. The address format puts the postcode in a different position — translatable. The driving rules are metric kph instead of mph — translatable but easy to misread.

And then there are the deeper differences. The work day starts and ends later. The default lunch is two hours, not thirty minutes. Negotiation styles favor relationship-first conversation. Healthcare is public and tax-funded, not employer-tied. These are not vocabulary substitutions — they are semantic shifts. The person who treats them as "just translate the words" will end up with problems that look like miscommunication but are actually genre-level mismatch.

An American passport stamped with a Spanish residency visa on a wooden desk beside a Spanish phrasebook open to a 'cultural differences' page; a half-unpacked moving box visible in the background.
An American passport stamped with a Spanish residency visa on a wooden desk beside a Spanish phrasebook open to a 'cultural differences' page; a half-unpacked moving box visible in the background.

Moving Oracle SQL to PostgreSQL is the same shape of move. Most translations are mechanical: SYSDATECURRENT_TIMESTAMP, NVLCOALESCE, DECODECASE WHEN. Like swapping currencies. But scattered through Oracle's idioms are semantic differences — the deepest one being Oracle's treatment of empty string as NULL, which PostgreSQL does NOT share. An Oracle query that worked correctly for ten years because '' and NULL were interchangeable will silently break in PostgreSQL where they are distinct values. The query still RUNS. It just stops returning the right rows. That is the kind of bug you do not catch until users notice the report's numbers drifted.

The translation guide is essential. The warning list is critical.

What it really is

Translations by category, each marked mechanical (1:1 substitute) or semantic (rewrite needed):

Date/time (mostly mechanical): SYSDATECURRENT_TIMESTAMP (or NOW(), or LOCALTIMESTAMP). ADD_MONTHS(d, n)d + n * INTERVAL '1 month'. MONTHS_BETWEEN(d1, d2) → no direct equivalent; compute via EXTRACT(YEAR FROM age(d1,d2))*12 + EXTRACT(MONTH FROM age(d1,d2)). TRUNC(date_col)DATE_TRUNC('day', date_col)::date.

NULL handling (mostly mechanical): NVL(a, b)COALESCE(a, b) — clean substitute. **SEMANTIC: '' = NULL in Oracle, '' ≠ NULL in PostgreSQL.** See gotcha #1.

String operations (mechanical): || for concat works in both. INSTR(s, sub)POSITION(sub IN s) (note argument order change). SUBSTRSUBSTRING or SUBSTR (PostgreSQL supports both).

Conditional (mechanical): DECODE(...)CASE WHEN ... THEN ... ELSE ... END.

Result limiting (mechanical): WHERE ROWNUM <= 10LIMIT 10 (add ORDER BY to make it deterministic). FETCH FIRST N ROWS ONLY works in both.

Quote / case sensitivity (semantic): Oracle folds unquoted identifiers to UPPERCASE; PostgreSQL folds to LOWERCASE. SPRIDEN_PIDM in Oracle is spriden_pidm in PostgreSQL. Cross-platform code should use quoted identifiers or rely consistently on the fold.

Oracle to PostgreSQL migration table Oracle PostgreSQL Type SYSDATE CURRENT_TIMESTAMP mechanical NVL(a, b) COALESCE(a, b) mechanical ROWNUM LIMIT N mechanical TO_CHAR(d, fmt) TO_CHAR(d, fmt) mechanical DUAL (no FROM) mechanical (+) outer join ANSI JOIN semantic - rewrite '' = NULL (true) '' = NULL (false) semantic - audit MERGE INSERT ... ON CONFLICT semantic - rewrite Semantic rows need careful rewriting - they are not 1:1 substitutions. Audit every IS NULL and = '' before the SaaS migration.
Three-column comparison: Oracle | PostgreSQL | mechanical-or-semantic flag. Rows highlighting SYSDATE/CURRENT_TIMESTAMP (mechanical), NVL/COALESCE (mechanical), (+)/ANSI JOIN (semantic — rewrite), ''=NULL/''!=NULL (semantic — audit), ROWNUM/LIMIT (mechanical), MERGE/ON CONFLICT (semantic).

**MERGE / UPSERT (semantic):** Oracle's MERGE INTO ... USING ... → PostgreSQL's INSERT ... ON CONFLICT (...) DO UPDATE SET .... Different syntax, similar semantics — a rewrite, not a substitution.

**(+) outer joins (semantic, mandatory rewrite):** PostgreSQL does NOT support (+). Every legacy Oracle query using (+) must be rewritten to ANSI JOIN before migration. See From (+) to ANSI — Retiring Oracle's Old Outer Join.

DUAL (mechanical removal): Oracle's SELECT SYSDATE FROM dual → PostgreSQL's SELECT CURRENT_TIMESTAMP (no FROM needed).

Sequence semantics (mechanical): Oracle's SEQ.NEXTVAL → PostgreSQL's nextval('seq'). Slightly different syntax; same semantics.

See it — the diagram

A three-column reference card: "Oracle," "PostgreSQL," and a "mechanical or semantic" flag in the third column. Mechanical rows in ink: SYSDATE/CURRENT_TIMESTAMP, NVL/COALESCE, DECODE/CASE, ROWNUM/LIMIT. Semantic rows in coral: (+)/ANSI JOIN (rewrite), ''=NULL/''≠NULL (audit every occurrence), MERGE/ON CONFLICT (rewrite). The visual says: most of the migration is a phrasebook; the flagged rows are where you stop translating and start auditing.

Show me the code

Oracle version:

-- Oracle: a typical Banner-flavored query.
SELECT s.spriden_id,
       s.spriden_last_name || ', ' ||
         NVL(s.spriden_first_name, '(blank)')  AS full_name,
       TO_CHAR(p.phrhist_activity_date, 'YYYY-MM-DD') AS posted_dt,
       p.phrhist_gross
FROM   phrhist p
JOIN   spriden s
       ON  s.spriden_pidm        = p.phrhist_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  p.phrhist_disp        = 'P'
  AND  p.phrhist_activity_date >= ADD_MONTHS(SYSDATE, -12)
  AND  ROWNUM <= 100
ORDER BY p.phrhist_activity_date DESC;

PostgreSQL translation:

-- PostgreSQL: NVL→COALESCE, SYSDATE→CURRENT_TIMESTAMP,
-- ADD_MONTHS→INTERVAL arithmetic, ROWNUM→LIMIT.
SELECT s.spriden_id,
       s.spriden_last_name || ', ' ||
         COALESCE(s.spriden_first_name, '(blank)') AS full_name,
       TO_CHAR(p.phrhist_activity_date, 'YYYY-MM-DD') AS posted_dt,
       p.phrhist_gross
FROM   phrhist p
JOIN   spriden s
       ON  s.spriden_pidm        = p.phrhist_pidm
       AND s.spriden_change_ind  IS NULL
       AND s.spriden_entity_ind  = 'P'
WHERE  p.phrhist_disp        = 'P'
  AND  p.phrhist_activity_date >= CURRENT_TIMESTAMP - INTERVAL '12 months'
ORDER BY p.phrhist_activity_date DESC
LIMIT 100;
Where intuition fails
  1. **'' = NULL in Oracle but NOT in PostgreSQL — the most dangerous gotcha.** Oracle treats empty string '' as NULL. WHERE x = '' returns NO rows in Oracle (NULL is never equal to anything). The same query in PostgreSQL returns rows where x is literally an empty string. Conversely, WHERE x IS NULL in Oracle catches both NULL and empty string; in PostgreSQL it catches only NULL. Audit every IS NULL and = '' in Banner SQL before migration.
  1. Unquoted identifier case-folding is opposite. Oracle folds to UPPERCASE; PostgreSQL folds to LOWERCASE. A column created as Spriden_Pidm becomes SPRIDEN_PIDM in Oracle and spriden_pidm in PostgreSQL. Use either consistently lowercase or quote every identifier for cross-database code.
  1. **DATEDIFF-style intervals are easier in PostgreSQL.** CURRENT_TIMESTAMP - INTERVAL '12 months' is clean and readable. The Oracle ADD_MONTHS equivalent is more verbose. Use the interval syntax in PostgreSQL.
  1. **MERGE syntax differs fundamentally.** Oracle's MERGE INTO target USING source ON (...) WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT becomes PostgreSQL's INSERT ... ON CONFLICT (...) DO UPDATE SET .... The semantics are similar but the syntax is a rewrite.
  1. **(+) outer joins are not supported.** PostgreSQL has no equivalent for Oracle's (+) syntax. Every Banner query using (+) must be rewritten to ANSI JOIN before it will run. See From (+) to ANSI — Retiring Oracle's Old Outer Join — audit your DataBlocks early; this is a common SaaS migration blocker.
The one-sentence takeaway

Oracle-to-PostgreSQL translation is mostly mechanical: SYSDATECURRENT_TIMESTAMP, NVLCOALESCE, DECODECASE, ROWNUMLIMIT, ADD_MONTHS+ INTERVAL. The semantic minefields: (1) '' = NULL in Oracle but '' ≠ NULL in PostgreSQL — audit every IS NULL and = ''; (2) unquoted identifiers fold to lowercase in PostgreSQL; (3) (+) outer joins are not supported — every one must be rewritten to ANSI JOIN before migration; (4) MERGEINSERT ... ON CONFLICT is a rewrite, not a substitution.

← All concepts
Track C · From generic SQL to Banner

From (+) to ANSI — Retiring Oracle's Old Outer Join

You open an older Banner SR report and see WHERE a.x = b.x(+). It looks like a typo. It is not. It is Oracle's pre-ANSI outer join syntax — the stick-shift of the SQL world. It still runs, but PostgreSQL won't accept it, and the modern world has moved on. Here is the translation.

6 min readoracleansi-joinlegacyplus-syntaxouter-joinbanner-saas
The hook

You open an older Banner SR report and see WHERE a.x = b.x(+). It looks like a typo. It is not. It is Oracle's pre-ANSI outer join syntax — the stick-shift of the SQL world. It still runs, but PostgreSQL won't accept it, and the modern world has moved on. Here is the translation.

The everyday analogy

An older driver learned to drive on a manual transmission: clutch in, shift to first, ease off the clutch while feathering the gas, shift to second at 15 mph, third at 25, fourth at 35. Every drive is an exercise in coordination — left foot, right foot, right hand on the gear lever, eyes on the tachometer, ears tuned for engine strain. The driver who masters this can squeeze every advantage out of the engine. But it takes years to internalize.

A younger driver learns on automatic. They never touch the clutch. The car decides when to shift. The driver focuses on steering and brake and throttle and traffic — the same outputs, far less mental overhead. The automatic transmission handles the gear math. It is the modern default. Driver's ed classes barely teach manual anymore.