You query PER_ALL_ASSIGNMENTS_M expecting one row per employee. Instead you get 12, 15, or 20 rows for a single person. Your headcount is off by an order of magnitude and you have no idea why.

This is one of the most common Oracle Fusion HCM SQL mistakes. The fix is straightforward once you understand how this table works โ€” but Oracle's documentation doesn't explain it clearly.

Why PER_ALL_ASSIGNMENTS_M Returns Multiple Rows

PER_ALL_ASSIGNMENTS_M stores the complete date-effective history of every assignment. Every time an assignment attribute changes โ€” a pay grade update, a position transfer, a location change, a manager change โ€” Oracle doesn't update the existing row. It end-dates the current row and creates a new row with the change.

For an employee who has been with the company 5 years and received annual pay reviews plus a promotion, you might see 10-15 rows in this table โ€” all for the same person and the same assignment.

There are also multiple rows per person for a second reason: assignment types. Oracle stores different types of work relationships as separate assignment rows. An employee might have an employee assignment (type E), a benefit assignment (type B), and a pending assignment (type P) all in the same table.

The Two Filters That Eliminate Duplicates

Filter 1: EFFECTIVE_LATEST_CHANGE = 'Y'

This column flags the most recent effective-dated row for each assignment. It's Oracle's standard mechanism for answering "what is this assignment's current state?" Without this filter, you get every historical version.

-- Without filter: returns all historical rows SELECT PERSON_ID, ASSIGNMENT_ID, GRADE_ID, EFFECTIVE_START_DATE FROM PER_ALL_ASSIGNMENTS_M WHERE PERSON_ID = 123456; -- Returns: 14 rows (all date-effective changes) -- With EFFECTIVE_LATEST_CHANGE filter: returns current row only SELECT PERSON_ID, ASSIGNMENT_ID, GRADE_ID, EFFECTIVE_START_DATE FROM PER_ALL_ASSIGNMENTS_M WHERE PERSON_ID = 123456 AND EFFECTIVE_LATEST_CHANGE = 'Y'; -- Returns: 1-2 rows (current state per assignment type)

Filter 2: ASSIGNMENT_TYPE = 'E'

Even with EFFECTIVE_LATEST_CHANGE = 'Y', you'll still get multiple rows if an employee has multiple assignment types. Filter to ASSIGNMENT_TYPE = 'E' to get only employee assignments.

ASSIGNMENT_TYPE Meaning Include in headcount?
E Employee Yes โ€” primary assignment type
C Contingent Worker Yes โ€” if counting contractors
N Non-Worker Usually no โ€” third-party non-headcount
B Benefit Relationship No โ€” benefits-only record, no work relationship
P Pending Worker No โ€” pre-hire, not yet active
O Offer No โ€” recruiting stage only

The Correct Headcount Query

โœ… One row per active employee โ€” correct query:
SELECT p.PERSON_NUMBER, p.FULL_NAME, a.ASSIGNMENT_NUMBER, a.ASSIGNMENT_STATUS_TYPE, a.GRADE_ID, a.JOB_ID, a.LOCATION_ID, a.DEPARTMENT_ID, a.MANAGER_ID, a.EFFECTIVE_START_DATE FROM PER_ALL_PEOPLE_F p JOIN PER_ALL_ASSIGNMENTS_M a ON p.PERSON_ID = a.PERSON_ID WHERE TRUNC(SYSDATE) BETWEEN p.EFFECTIVE_START_DATE AND p.EFFECTIVE_END_DATE AND a.EFFECTIVE_LATEST_CHANGE = 'Y' AND a.ASSIGNMENT_TYPE = 'E' AND a.ASSIGNMENT_STATUS_TYPE = 'ACTIVE_ASSIGN';

This query returns exactly one row per active employee. Let's break down each filter:

Why Not Just Filter by Date Range?

A common mistake is filtering by TRUNC(SYSDATE) BETWEEN EFFECTIVE_START_DATE AND EFFECTIVE_END_DATE on the assignment table instead of using EFFECTIVE_LATEST_CHANGE. This often works but has edge cases:

โš ๏ธ Date-range filter โ€” works most of the time but has edge cases:
-- This can still return multiple rows for employees with future-dated changes WHERE TRUNC(SYSDATE) BETWEEN a.EFFECTIVE_START_DATE AND a.EFFECTIVE_END_DATE AND a.ASSIGNMENT_TYPE = 'E'

The problem: if an assignment change is entered with a future effective date (e.g., a promotion that starts next week), Oracle creates a new row with EFFECTIVE_START_DATE in the future. For that employee, two rows overlap today โ€” the current assignment and the future-dated one. The date range filter will return both.

EFFECTIVE_LATEST_CHANGE = 'Y' handles this correctly by only flagging the most recently entered change, regardless of effective date.

Best practice: Use EFFECTIVE_LATEST_CHANGE = 'Y' as your primary deduplication filter. Use date range as a secondary filter only if you need to report as-of a specific historical date.

As-Of Date Queries: Reporting on Past or Future State

If you need to report on assignment data as of a specific date (not today), replace EFFECTIVE_LATEST_CHANGE with a date-range filter. This is the correct pattern for point-in-time reporting:

-- Assignments as of a specific date (e.g., end of last quarter) SELECT p.PERSON_NUMBER, p.FULL_NAME, a.ASSIGNMENT_NUMBER, a.GRADE_ID, a.DEPARTMENT_ID FROM PER_ALL_PEOPLE_F p JOIN PER_ALL_ASSIGNMENTS_M a ON p.PERSON_ID = a.PERSON_ID WHERE DATE '2025-12-31' BETWEEN p.EFFECTIVE_START_DATE AND p.EFFECTIVE_END_DATE AND DATE '2025-12-31' BETWEEN a.EFFECTIVE_START_DATE AND a.EFFECTIVE_END_DATE AND a.ASSIGNMENT_TYPE = 'E' AND a.ASSIGNMENT_STATUS_TYPE = 'ACTIVE_ASSIGN';

For historical queries, date-range filtering is correct โ€” EFFECTIVE_LATEST_CHANGE only applies to the current state.

Key Columns Reference

Column Purpose Common Filter Value
EFFECTIVE_LATEST_CHANGE Flags the most recent date-effective row per assignment 'Y' for current state
ASSIGNMENT_TYPE Type of work relationship 'E' for employee headcount
ASSIGNMENT_STATUS_TYPE Active vs terminated vs suspended 'ACTIVE_ASSIGN' for active employees
EFFECTIVE_START_DATE When this version of the assignment started Use with EFFECTIVE_END_DATE for point-in-time
EFFECTIVE_END_DATE When this version ends (4712-12-31 = no end date) 4712-12-31 = open-ended (current)
PRIMARY_FLAG Marks the primary assignment when a worker has multiple 'Y' if you only want the primary assignment
PERSON_ID FK to PER_ALL_PEOPLE_F Join key
ASSIGNMENT_ID Unique assignment identifier Used for joining to other assignment tables

Multiple Assignments Per Person

Some organizations allow workers to hold multiple active assignments simultaneously (e.g., a part-time worker in two departments). In this case, even with EFFECTIVE_LATEST_CHANGE = 'Y' and ASSIGNMENT_TYPE = 'E', you may still see two rows per person.

To get only the primary assignment:

-- Add PRIMARY_FLAG to get exactly one row per worker WHERE a.EFFECTIVE_LATEST_CHANGE = 'Y' AND a.ASSIGNMENT_TYPE = 'E' AND a.ASSIGNMENT_STATUS_TYPE = 'ACTIVE_ASSIGN' AND a.PRIMARY_FLAG = 'Y'

Use PRIMARY_FLAG = 'Y' only if your organization uses primary assignment designation. If it's not populated, this filter will exclude valid employees.

Explore the Full PER_ALL_ASSIGNMENTS_M Schema

View all columns for PER_ALL_ASSIGNMENTS_M and every other Oracle Fusion HCM table. Find foreign key relationships, data types, and join paths between assignment, person, and payroll tables.

Search Oracle HCM Tables โ†’

Summary

PER_ALL_ASSIGNMENTS_M returns multiple rows per employee because:

  1. Date-effective history โ€” every change creates a new row. Fix: add EFFECTIVE_LATEST_CHANGE = 'Y'
  2. Multiple assignment types โ€” employee, benefit, pending rows coexist. Fix: add ASSIGNMENT_TYPE = 'E'
  3. Terminated/inactive assignments โ€” still stored after termination. Fix: add ASSIGNMENT_STATUS_TYPE = 'ACTIVE_ASSIGN'
  4. Multiple active assignments โ€” workers in multiple roles. Fix: add PRIMARY_FLAG = 'Y' if needed

The standard current-state headcount query combines all four: EFFECTIVE_LATEST_CHANGE = 'Y' + ASSIGNMENT_TYPE = 'E' + ASSIGNMENT_STATUS_TYPE = 'ACTIVE_ASSIGN'. Add PRIMARY_FLAG = 'Y' only if your implementation uses it.