Ellucian Banner Records When a Major Started. No Column Records When It Stopped.

A replicated Banner lands every column and 99 correct foreign keys on the student record, and still cannot say how many students are in each major. The semantic model chooses the in-force row.

banner sgbstdn effective term data warehouse reporting: 109 columns and 99 correct foreign keys, and no column recording when a student record stopped being true
One student, four SGBSTDN rows, four effective terms. Banner writes a row when something changes and never closes the one it supersedes, so the obvious filters return either the students who changed something this term or every major anyone ever held. Source: Agami original diagram, from the Victoria College Banner ERD v7 for SGBSTDN.

Query Ellucian Banner data with AI against a replicated Oracle schema and the simplest reporting question in the building, how many students are in each major, has no honest one-table answer. Not because a column is missing. Every column lands, the student record carries 99 declared foreign keys that are all correct, and the effective term column that looks like the answer is not the answer.

A provost asks how many students are in each major this term.

Point an AI at the landed SATURN schema and it has no trouble at all. SGBSTDN is the student program record. SGBSTDN_MAJR_CODE_1 is a declared foreign key to STVMAJR, which holds the label. SGBSTDN_TERM_CODE_EFF is a declared foreign key to STVTERM, which holds the terms. Filter to this term, group by major, count.

The query compiles. Every major on the list is a real major. Every number is small enough to be believable.

It is roughly the number of students who changed something this term.

Before you start

  • Banner already landed in a warehouse. This post is written for the team that has it. There is no Banner connector: Fivetran's own page says Banner "is built on the Oracle database", that "instead of pulling data from the Ellucian Banner API, we pull directly from the source database", and that to sync it you should use its Oracle connector. Under its "Schema information" heading, the entire content is a pointer to Ellucian's technical reference manual for your version.
  • There is no free instance, and not close. Banner is licensed to institutions under multi-year agreements. There is no trial, no developer edition and no public sandbox, and Ethos access runs through the Ellucian Customer Center on institutional approval. Everything below runs against your own replicated schema, and a reader who is not at a Banner institution cannot follow along at all.
  • This is Banner, not Colleague. Ellucian sells two student information systems. Colleague is a different product on a different database with different table names, and nothing here has been checked against it.
  • Casing varies by extract tool. Oracle stores unquoted identifiers upper case, Snowflake normalises up, BigQuery and Redshift down. This post writes SQL lower case and table documentation upper. Adjust to whatever your destination did.
  • The grain may already have been fixed for you. If your warehouse came through Banner ODS or Banner EDW rather than a direct replication of Baseline, the problem below was solved upstream and this post does not apply to your estate. Check which one you have before you start.

The question

"How many students are in each major?"

In higher education this is not a reporting curiosity. It is the enrollment report, it is what goes to the board, it is what drives faculty lines and program review, and it is the number a registrar has been producing correctly for twenty years. It is asked constantly, it is asked of the warehouse the moment there is one, and the institution already knows the right answer.

What breaks

Here is the query, with both join keys declared foreign keys:

select m.stvmajr_desc as major,
       count(*)       as students
from   sgbstdn s
join   stvmajr m on m.stvmajr_code = s.sgbstdn_majr_code_1
where  s.sgbstdn_term_code_eff = '202610'
group  by 1
order  by 2 desc;

Nothing about that is careless. sgbstdn_majr_code_1 really does point at stvmajr_code. sgbstdn_term_code_eff really does point at stvterm_code. An introspection pass over this schema finds both and gets the cardinality right.

It returns the students whose program record changed effective this term. A continuing student who declared their major two years ago and has not touched it since has no row for this term, so they are not in the answer. The result is small, plausible, and a fraction of the institution.

So you widen it, which is the reflex fix and the more dangerous one:

select m.stvmajr_desc                  as major,
       count(distinct s.sgbstdn_pidm)  as students
from   sgbstdn s
join   stvmajr m on m.stvmajr_code = s.sgbstdn_majr_code_1
where  s.sgbstdn_term_code_eff <= '202610'
group  by 1
order  by 2 desc;

Now every student who ever switched majors is counted once in the major they left and once in the major they joined. The count(distinct ...) looks like it is protecting you, and it is: within each major. Across majors it protects nothing. Every per-major figure is inflated, the column sums to more students than the institution has, and no single row looks wrong.

One student, four SGBSTDN rows across four effective terms. Filtering on the term returns only the row that changed that term; filtering on or before it returns all four, counting the student in every major they ever held. Only the greatest effective term not later than the term asked about returns the row actually in force

A worked example of one student's program history, laid out against the key structure the Banner ERD documents. Banner writes a row when something changes and never closes the row it supersedes. Major codes are institution-defined, so yours will differ.

Why it breaks

Start with the primary key, because the whole problem is visible there.

PK_SGBSTDN is unique on exactly two columns:

SGBSTDN_PIDM   SGBSTDN_TERM_CODE_EFF

One row per student per effective term. A student who enrolls, changes nothing, and graduates has one row. A student who switches major twice and takes a leave has four. The row is not a snapshot of a term. It is the start of an interval, and Banner writes it only when something changes.

Now count what is on the table, from the published Banner ERD. SGBSTDN lands with 109 columns, and not one of them closes that interval. There is no end term, no expiry, no superseded flag, no valid-to. The only column on the whole record whose name mentions an end of anything is SGBSTDN_LEAV_TO_DATE, and that closes a leave of absence rather than the record's validity. Banner models a closed interval when it means one. Here it deliberately did not.

That leaves the schema unable to say the one thing the question needs: which row was in force.

And the schema is otherwise immaculate. The ERD's foreign-key block for SGBSTDN holds 99 declared foreign keys, covering 98 of the 109 columns, pointing at 39 distinct tables, 32 of which are STV* validation tables. Every code on the record has a labelled home and a declared path to it. An agent pointed at a replicated Banner finds all of them, correctly, with no help whatsoever.

This is worth saying plainly, because it is the opposite of what the rest of this series describes. When ServiceNow puts a join where no foreign key exists, or Business Central computes six of eight dimension columns on read so they never land at all, something is missing from the warehouse. Here nothing is missing. A raw Oracle replication is lossless in structure: every table, every column, every constraint, in Banner's own names. And the number is still wrong.

It is also a different failure from ServiceTitan's, where every foreign key is likewise correct. That one is a fan-out: the join produces too many rows and they have to be weighted. This one produces exactly the rows it should and selects the wrong ones. There is nothing to weight and nothing to deduplicate. The question is which rows were true on a given date, and no join expresses that.

Ten term columns, and only one of them selects rows

The trap has a second half that makes it much easier to fall into.

SGBSTDN carries ten columns named SGBSTDN_TERM_CODE_*, and all ten are declared foreign keys to STVTERM:

SGBSTDN_TERM_CODE_EFF      SGBSTDN_TERM_CODE_MATRIC
SGBSTDN_TERM_CODE_ADMIT    SGBSTDN_TERM_CODE_ADMIT_2
SGBSTDN_TERM_CODE_ASTD     SGBSTDN_TERM_CODE_GRAD
SGBSTDN_TERM_CODE_CTLG_1   SGBSTDN_TERM_CODE_CTLG_2
SGBSTDN_TERM_CODE_PREV     SGBSTDN_TERM_CODE_CAST

Nine of them are facts about the student: the term they matriculated, the term they were admitted, the term they are expected to graduate, the catalog year their degree requirements come from. Exactly one, _EFF, is a fact about the row.

Nothing in the schema distinguishes them. They have the same datatype, the same target table, the same kind of name, and all ten are correct foreign keys. An agent choosing among them by name alone is guessing, and _EFF is not the most obvious guess when the question says "this term".

What Banner did for you

Here is the part worth sitting with. This is not an oversight anywhere in the chain.

Banner resolves the effective term on every screen a registrar has ever looked at. The SGASTDN form navigates by term: you give it a term, and it shows you the record in force, deriving the boundary for display rather than reading it from a column. Twenty years of correct, point-in-time program records, and nobody ever wrote a subquery.

Banner also ships code whose entire job is this. The ERD's list of objects that call SGBSTDN includes a C function named select_current_major, in SHREDIY.PC. Replication copies the rows. It does not copy the function.

And Ellucian sells a product for it. Banner ODS and Banner EDW read Banner and republish it in a reporting shape, and removing this exact logic is part of what they are for. A practitioner writing about Banner reporting puts it in a parenthesis, as something everyone in the field already knows:

"Effective dating … is one of the most common topics of discussion when reporting against Banner (Baseline, especially: the ODS and EDW go a long way to removing effective date and term code logic requirements)."

A direct Oracle replication goes around all three. It copies SATURN faithfully, which means it copies Baseline: the form's resolution is not in it, the C function is not in it, and the ODS is not in the path. What lands is precisely the shape Ellucian built a separate product to get away from.

This is the same shape as Salesforce's "Opportunities with Products" report type holding the line-item grain, and the same shape as ServiceNow's table inheritance: the thing that made the application's number correct was never a column, so replication had nothing to copy.

The fix

None of this is a query problem. Rewriting the SQL fixes one question for one person on one afternoon, and the next person to ask about enrollment starts from the same schema and reaches the same wrong number by the same correct route.

It belongs in the semantic model, where the resolution is declared once and every question inherits it.

The joins go in first, and the notable thing about them is how ordinary they are. Every one is a foreign key Banner already declares:

# subject_areas/student/sgbstdn.yaml
relationships:
  - name: sgbstdn_to_spriden
    from: { table: sgbstdn, columns: [sgbstdn_pidm] }
    to:   { table: spriden, columns: [spriden_pidm] }
    cardinality: many_to_one
    citation: >
      Banner ERD v7, SGBSTDN. PIDM is Banner's internal person surrogate and is
      the only correct join key from a student record to a person. Do NOT join
      on spriden_id, the visible identifier, which is not unique across spriden
      rows. spriden also holds many rows per person, so this join needs
      spriden_change_ind IS NULL on the spriden side or it duplicates every
      person who has ever changed their name.

  - name: sgbstdn_to_stvterm_eff
    from: { table: sgbstdn, columns: [sgbstdn_term_code_eff] }
    to:   { table: stvterm, columns: [stvterm_code] }
    cardinality: many_to_one
    citation: >
      Banner ERD v7, SGBSTDN foreign-key block. This is the term the row TOOK
      EFFECT, not the term the row describes. Nine other sgbstdn columns are
      also declared foreign keys to stvterm and are attributes of the student
      rather than of the row: _MATRIC, _ADMIT, _ADMIT_2, _ASTD, _GRAD, _CTLG_1,
      _CTLG_2, _PREV, _CAST. None of them selects rows.

  - name: sgbstdn_to_stvmajr
    from: { table: sgbstdn, columns: [sgbstdn_majr_code_1] }
    to:   { table: stvmajr, columns: [stvmajr_code] }
    cardinality: many_to_one
    citation: >
      Banner ERD v7. The primary major of the primary curriculum. Codes are
      institution-defined and only stvmajr carries the label.

Then the part that carries the weight. The schema cannot say which row is in force, so the model has to:

entities:
  - name: banner_student_program_record_as_of_term
    description: >
      The sgbstdn row in force for a student as of a given term. Banner writes a
      row only when something changes and the row stays in force until a later
      row for the same PIDM supersedes it. sgbstdn has 109 columns and none of
      them records when a row stopped being true; the primary key is
      (sgbstdn_pidm, sgbstdn_term_code_eff) and term_code_eff is the START of an
      interval. The only column naming an end of anything is
      sgbstdn_leav_to_date, which closes a leave of absence and not the record.
      So no filter on sgbstdn alone selects the right rows.
    resolves_to:
      table: sgbstdn
      grain: one row per student per effective term, written on change only
      selector: >
        sgbstdn_term_code_eff = (SELECT MAX(x.sgbstdn_term_code_eff)
                                 FROM sgbstdn x
                                 WHERE x.sgbstdn_pidm = sgbstdn.sgbstdn_pidm
                                   AND x.sgbstdn_term_code_eff <= :as_of_term)
      forbidden_selectors:
        - >
          sgbstdn_term_code_eff = :as_of_term
          (returns only students who CHANGED something that term; a severe
          undercount that looks like a plausible small number)
        - >
          sgbstdn_term_code_eff <= :as_of_term without the MAX subquery
          (returns every program the student ever held; overcounts every student
          who switched, once per major)
    caveats:
      - >
        Term codes sort lexically in Banner's YYYYMM convention, which is why
        MAX() and <= work on a VARCHAR2(6). That is a property of the
        institution's coding convention and is not enforced by anything. Confirm
        it against stvterm's start dates before relying on it.
      - >
        This is a Banner fact. Ellucian Colleague is a different product on a
        different database and nothing here has been checked against it.
      - >
        If the warehouse came through Banner ODS or EDW, the grain is already
        term-by-term and this entity is wrong for that estate.

And the metric binds to the entity rather than to the table:

metrics:
  - name: headcount_by_major
    calculation: >
      Students in each primary major as of a given term, counted from the
      sgbstdn row in force for each student at that term, with the major code
      decoded through stvmajr.
    requires_entity: banner_student_program_record_as_of_term
    source_tables: [sgbstdn, stvmajr]
    primary_table: sgbstdn
    other_names: [headcount by major, students by major, enrollment by major]
    citation: >
      Banner ERD v7, SGBSTDN. The entity supplies the row selection, which is
      the part the schema cannot express.

requires_entity is the load-bearing line. It says that reaching sgbstdn for a headcount without the in-force selector is not a stylistic choice, it is a number the model has already declared invalid. An agent that filters on the bare effective term gets refused rather than answered.

Four decisions in here are not inferable from the schema, and a person makes them once.

Which student statuses count as enrolled. STVSTST codes are institution-defined. Whether a student on approved leave is in the headcount is a policy your institution has already written down somewhere, and no introspection recovers it.

Whether term codes sort. The MAX() selector assumes your STVTERM codes sort in the order the terms occur. That is the near-universal convention and it is not a guarantee. One person checks it once against STVTERM's start dates.

Which curriculum is "the" major. SGBSTDN carries a primary and a secondary curriculum, dual-degree columns, and concentrations. Whether a double-major student counts once, twice, or one half in each is an institutional reporting rule, and it changes the total.

Census date versus live. Institutions report enrollment as of a frozen census date. The :as_of_term parameter gets the term right and says nothing about the freeze. A person supplies that date.

Reproduce it yourself

This runs against your own warehouse, which is the only route available for Banner.

  1. Run the shape query. It tells you how exposed you are before you change anything.
  2. Run the wrong query from the top of this post. Keep the output.
  3. Run the gap query. It puts a number on what step 2 has been quietly adding.
  4. Declare the relationships, the entity and the metric, then ask the question in plain English in the assistant your team already opens, and compare against the registrar's census for the same term.

The shape query. Run this first:

select rows_per_student,
       count(*) as students
from (
  select sgbstdn_pidm,
         count(*) as rows_per_student
  from   sgbstdn
  where  sgbstdn_term_code_eff <= '202610'
  group  by sgbstdn_pidm
) t
group  by 1
order  by 1;

Every student on a row above 1 has a program history that the naive filters cannot read. If everyone is on 1, your institution locks cohorts and you can stop here. Most do not.

The gap query. This is the one worth keeping:

with in_force as (
  select s.sgbstdn_pidm,
         s.sgbstdn_majr_code_1
  from   sgbstdn s
  where  s.sgbstdn_term_code_eff = (
           select max(x.sgbstdn_term_code_eff)
           from   sgbstdn x
           where  x.sgbstdn_pidm = s.sgbstdn_pidm
             and  x.sgbstdn_term_code_eff <= '202610'
         )
),
ever_held as (
  select distinct sgbstdn_pidm, sgbstdn_majr_code_1
  from   sgbstdn
  where  sgbstdn_term_code_eff <= '202610'
)
select (select count(*) from ever_held)                             as naive_major_rows,
       (select count(*) from in_force)                              as actual_students,
       (select count(*) from ever_held) - (select count(*) from in_force) as phantom_students
from   dual;

phantom_students is your own number, computed on your own estate. Every one of them is a student being counted in a major they left. Where cohorts are locked it is near zero. At an open-enrollment college where students shop programs it is a large fraction of the headcount.

Then break it out by college or by division. Programs that students transfer into will carry most of the error, and seeing which departments the inflation concentrates in is far more persuasive than the total.

The corrected query. The correlated subquery is the entire fix:

select m.stvmajr_desc as major,
       count(*)       as students
from   sgbstdn s
join   stvmajr m on m.stvmajr_code = s.sgbstdn_majr_code_1
join   stvstst t on t.stvstst_code = s.sgbstdn_stst_code
where  s.sgbstdn_term_code_eff = (
         select max(x.sgbstdn_term_code_eff)
         from   sgbstdn x
         where  x.sgbstdn_pidm = s.sgbstdn_pidm
           and  x.sgbstdn_term_code_eff <= '202610'
       )
group  by 1
order  by 2 desc;

It reads "the row that was in force for this student as of this term", which is the sentence the schema cannot say and the semantic model has to.

Frequently asked questions

Why does my Banner headcount by major not match the registrar's census?

Almost certainly because the query is filtering SGBSTDN_TERM_CODE_EFF directly. SGBSTDN holds one row per student per effective term and Banner writes a row only when something changes, so filtering on a term returns only the students who changed something that term, and filtering on or before it returns every major each student has ever held. The row in force is the one with the greatest effective term not later than the term you are asking about, and selecting it takes a correlated subquery.

What is SGBSTDN_TERM_CODE_EFF?

The term the student's program record took effect. It is the second half of the table's primary key, alongside SGBSTDN_PIDM. It is the start of an interval that nothing closes: among the 109 columns on SGBSTDN there is no end term, no expiry and no superseded flag, and the only column naming an end of anything is SGBSTDN_LEAV_TO_DATE, which closes a leave of absence.

Which of the ten term columns on SGBSTDN should I filter on?

SGBSTDN_TERM_CODE_EFF, and only inside a subquery that picks its maximum. The other nine are _MATRIC, _ADMIT, _ADMIT_2, _ASTD, _GRAD, _CTLG_1, _CTLG_2, _PREV and _CAST, and they are attributes of the student rather than of the row: when they matriculated, when they were admitted, which catalog year their requirements come from. All ten are declared foreign keys to STVTERM and nothing in the schema distinguishes them.

Can I just take the latest row per student?

Only if the question is about today. The latest row overall is not the row in force at a past term, so any term-over-term trend, any census-date report and any year-over-year comparison needs the effective term bounded by the term being asked about. That is why the selector is MAX(...) WHERE term_code_eff <= :as_of_term rather than a plain MAX().

Does this apply if my warehouse came through Banner ODS?

No. Banner ODS and Banner EDW republish Banner in a reporting shape and remove much of the effective-dating logic on the way, which is part of what they are for. Institutions that build their own warehouse frequently do the same thing by hand, exploding the interval into one row per term on the way in. This post is about a direct Oracle replication of Baseline, which is the route Fivetran documents.

References

  1. Banner ERD v7, SGBSTDN, Victoria College. Every structural claim in this post: the 109 columns, PK_SGBSTDN unique on SGBSTDN_PIDM and SGBSTDN_TERM_CODE_EFF, the 99 declared foreign keys covering 98 columns against 39 tables of which 32 are STV*, the ten STVTERM columns, the absence of any validity-end column, and select_current_major in SHREDIY.PC. An institution's published copy of a version-specific schema rather than a vendor publication, and readable in full.
  2. Ellucian Banner connector, Fivetran. That there is no Banner connector, that the route is the Oracle connector, and that Fivetran publishes no Banner schema.
  3. Oracle connector, Fivetran. The actual setup path.
  4. Effective Dating and Sub Queries, Banner Reporting. The practitioner statement of the hazard, the correlated-subquery fix, and the ODS and EDW parenthesis quoted above.
  5. agami-core on GitHub

Make your Banner data answerable

Agami is the semantic layer between your AI assistant and your warehouse. It declares what each column means and which row is actually in force, so an agent returns a governed number or says why it cannot.

Start a free trial or talk to us →