How to Create and Optimize a SQL Server Report Query for User Bank Consent Data

When working with a Microsoft SQL Server database, it is common to need reports that combine data from several related tables. In banking, open banking, or PSD2-style applications, one frequent requirement is to generate a report showing how many accounts, cards, loans, savings accounts, and balances are connected to each user consent.

This article explains, at a beginner-friendly level, how to write and optimize a SQL Server SELECT query that creates such a report.

Example Reporting Requirement

Assume we need a report with the following columns:

  • User ID
  • Bank ID
  • Consent created date
  • Number of accounts
  • Number of account balances
  • Number of cards
  • Number of card balances
  • Number of loans
  • Number of loan balances
  • Number of savings
  • Number of savings balances

The database contains tables such as:

  • consent
  • bank_user
  • consent_account
  • consent_account_balance
  • consent_card
  • consent_card_balance
  • loans_accounts
  • consent_loan_balance
  • savings_accounts
  • consent_saving_balance

The main table is usually consent, because all account, card, loan, and saving data is linked to a consent ID.

Basic SQL Report Query

A possible SQL Server query is:

SELECT 
    bu.user_id AS [User ID],
    bu.bank_id AS [Bank ID],
    c.create_time AS [Consent created date],

    COUNT(DISTINCT acc.resource_id) AS [Number of accounts],
    COUNT(DISTINCT acc_bal.balance_id) AS [Number of accounts balances],

    COUNT(DISTINCT card.resource_id) AS [Number of cards],
    COUNT(DISTINCT card_bal.balance_id) AS [Number of cards balances],

    COUNT(DISTINCT loan.resource_id) AS [Number of loans],
    COUNT(DISTINCT loan_bal.balance_id) AS [Number of loans balances],

    COUNT(DISTINCT sav.resource_id) AS [Number of savings],
    COUNT(DISTINCT sav_bal.balance_id) AS [Number of savings balances]

FROM dbo.bank_user bu
INNER JOIN dbo.consent c
    ON bu.bank_id = c.bank_id
   AND bu.user_id = c.user_id

LEFT JOIN dbo.consent_account acc
    ON c.id = acc.consent_id

LEFT JOIN dbo.consent_account_balance acc_bal
    ON acc.consent_id = acc_bal.consent_id
   AND acc.resource_id = acc_bal.resource_id

LEFT JOIN dbo.consent_card card
    ON c.id = card.consent_id

LEFT JOIN dbo.consent_card_balance card_bal
    ON card.consent_id = card_bal.consent_id
   AND card.resource_id = card_bal.resource_id

LEFT JOIN dbo.loans_accounts loan
    ON c.id = loan.consent_id

LEFT JOIN dbo.consent_loan_balance loan_bal
    ON loan.consent_id = loan_bal.consent_id
   AND loan.resource_id = loan_bal.resource_id

LEFT JOIN dbo.savings_accounts sav
    ON c.id = sav.consent_id

LEFT JOIN dbo.consent_saving_balance sav_bal
    ON sav.consent_id = sav_bal.consent_id
   AND sav.resource_id = sav_bal.resource_id

GROUP BY 
    bu.user_id,
    bu.bank_id,
    c.create_time;

Why LEFT JOIN Is Used

LEFT JOIN is important because not every consent has all types of data.

For example, a user may have:

  • bank accounts, but no loans;
  • cards, but no savings;
  • savings, but no cards.

Using LEFT JOIN allows the report to still show the consent even when some related data is missing.

Why COUNT DISTINCT Is Used

When joining many one-to-many tables, SQL can multiply rows.

For example, if a consent has:

  • 2 accounts;
  • 3 balances;
  • 2 cards;

a normal COUNT(*) could return inflated results.

That is why COUNT(DISTINCT column) is used. It counts unique values only.

Example:

COUNT(DISTINCT acc.resource_id)

This counts each account only once.

A More Optimized Version

For large databases, joining all tables directly can become slow. A better approach is to pre-aggregate each table first, then join the summarized results.

WITH account_counts AS (
    SELECT consent_id, COUNT(DISTINCT resource_id) AS number_of_accounts
    FROM dbo.consent_account
    GROUP BY consent_id
),
account_balance_counts AS (
    SELECT consent_id, COUNT(DISTINCT balance_id) AS number_of_account_balances
    FROM dbo.consent_account_balance
    GROUP BY consent_id
),
card_counts AS (
    SELECT consent_id, COUNT(DISTINCT resource_id) AS number_of_cards
    FROM dbo.consent_card
    GROUP BY consent_id
),
card_balance_counts AS (
    SELECT consent_id, COUNT(DISTINCT balance_id) AS number_of_card_balances
    FROM dbo.consent_card_balance
    GROUP BY consent_id
),
loan_counts AS (
    SELECT consent_id, COUNT(DISTINCT resource_id) AS number_of_loans
    FROM dbo.loans_accounts
    GROUP BY consent_id
),
loan_balance_counts AS (
    SELECT consent_id, COUNT(DISTINCT balance_id) AS number_of_loan_balances
    FROM dbo.consent_loan_balance
    GROUP BY consent_id
),
saving_counts AS (
    SELECT consent_id, COUNT(DISTINCT resource_id) AS number_of_savings
    FROM dbo.savings_accounts
    GROUP BY consent_id
),
saving_balance_counts AS (
    SELECT consent_id, COUNT(DISTINCT balance_id) AS number_of_saving_balances
    FROM dbo.consent_saving_balance
    GROUP BY consent_id
)
SELECT
    c.user_id AS [User ID],
    c.bank_id AS [Bank ID],
    c.create_time AS [Consent created date],

    ISNULL(ac.number_of_accounts, 0) AS [Number of accounts],
    ISNULL(abc.number_of_account_balances, 0) AS [Number of accounts balances],

    ISNULL(cc.number_of_cards, 0) AS [Number of cards],
    ISNULL(cbc.number_of_card_balances, 0) AS [Number of cards balances],

    ISNULL(lc.number_of_loans, 0) AS [Number of loans],
    ISNULL(lbc.number_of_loan_balances, 0) AS [Number of loans balances],

    ISNULL(sc.number_of_savings, 0) AS [Number of savings],
    ISNULL(sbc.number_of_saving_balances, 0) AS [Number of savings balances]

FROM dbo.consent c

LEFT JOIN account_counts ac
    ON c.id = ac.consent_id

LEFT JOIN account_balance_counts abc
    ON c.id = abc.consent_id

LEFT JOIN card_counts cc
    ON c.id = cc.consent_id

LEFT JOIN card_balance_counts cbc
    ON c.id = cbc.consent_id

LEFT JOIN loan_counts lc
    ON c.id = lc.consent_id

LEFT JOIN loan_balance_counts lbc
    ON c.id = lbc.consent_id

LEFT JOIN saving_counts sc
    ON c.id = sc.consent_id

LEFT JOIN saving_balance_counts sbc
    ON c.id = sbc.consent_id

ORDER BY 
    c.user_id,
    c.bank_id,
    c.create_time;

Why This Version Can Be Faster

The optimized version first counts data inside each table separately. This avoids large row multiplication caused by joining all detailed tables at once.

Instead of joining thousands or millions of raw rows, SQL Server joins smaller grouped results.

This can improve:

  • execution time;
  • memory usage;
  • readability;
  • accuracy of counts.

Recommended Indexes

Indexes are very important for this type of report.

Recommended indexes include:

CREATE INDEX IX_consent_user_bank
ON dbo.consent (user_id, bank_id, id, create_time);

CREATE INDEX IX_consent_account_consent
ON dbo.consent_account (consent_id, resource_id);

CREATE INDEX IX_consent_account_balance_consent
ON dbo.consent_account_balance (consent_id, resource_id, balance_id);

CREATE INDEX IX_consent_card_consent
ON dbo.consent_card (consent_id, resource_id);

CREATE INDEX IX_consent_card_balance_consent
ON dbo.consent_card_balance (consent_id, resource_id, balance_id);

CREATE INDEX IX_loans_accounts_consent
ON dbo.loans_accounts (consent_id, resource_id);

CREATE INDEX IX_consent_loan_balance_consent
ON dbo.consent_loan_balance (consent_id, resource_id, balance_id);

CREATE INDEX IX_savings_accounts_consent
ON dbo.savings_accounts (consent_id, resource_id);

CREATE INDEX IX_consent_saving_balance_consent
ON dbo.consent_saving_balance (consent_id, resource_id, balance_id);

Common Mistakes to Avoid

1. Using COUNT(*)

COUNT(*) may return wrong numbers when several child tables are joined together.

Use:

COUNT(DISTINCT resource_id)

or pre-aggregate with GROUP BY.

2. Joining Balance Tables Incorrectly

Balance tables should usually be joined by both:

consent_id
resource_id

This helps avoid counting balances from the wrong account, card, loan, or saving.

3. Forgetting NULL Values

When using LEFT JOIN, missing data returns NULL.

Use:

ISNULL(value, 0)

to show zero instead of NULL in the report.

4. Missing Indexes

Without indexes, SQL Server may need to scan entire tables, making the report slow.

Conclusion

To create a SQL Server report for user banking consent data, start from the consent table and join the related account, card, loan, saving, and balance tables.

For small databases, a direct query with LEFT JOIN and COUNT(DISTINCT) may be enough.

For larger databases, a better solution is to pre-aggregate each table using CTEs or subqueries, then join the summarized results.

This approach produces a cleaner, faster, and more reliable report.

This article is inspired by real-world challenges we tackle in our projects. If you're looking for expert solutions or need a team to bring your idea to life,

Let's talk!

    Please fill your details, and we will contact you back

      Please fill your details, and we will contact you back