<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dorcas Chebet</title>
    <description>The latest articles on DEV Community by Dorcas Chebet (@dorcas_chebet).</description>
    <link>https://dev.to/dorcas_chebet</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4070954%2F0facd9ba-bb92-4198-87ed-3e9925892ddb.png</url>
      <title>DEV Community: Dorcas Chebet</title>
      <link>https://dev.to/dorcas_chebet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dorcas_chebet"/>
    <language>en</language>
    <item>
      <title>SQL FOR ANALYTICS:WINDOW FUNCTIONS,JOINS,CTEs AND SUBQUERIES</title>
      <dc:creator>Dorcas Chebet</dc:creator>
      <pubDate>Sun, 18 Oct 2026 13:25:03 +0000</pubDate>
      <link>https://dev.to/dorcas_chebet/sql-for-analyticswindow-functionsjoinsctes-and-subqueries-4gdh</link>
      <guid>https://dev.to/dorcas_chebet/sql-for-analyticswindow-functionsjoinsctes-and-subqueries-4gdh</guid>
      <description>&lt;h1&gt;
  
  
  Introduction
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;The Hospital Dataset is Used in this Article&lt;/em&gt;&lt;br&gt;
The dataset is structured as a star schema created from a single flat file containing hospital visit data called hospital_visits_flat_original. A main fact table keeps track of every visit, and six dimension tables explain who was involved, where the visit took place, and why it happened. Queries are written using PostgreSQL syntax, and there are notes explaining any differences when the same query would work in MySQL or SQL Server.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQL Window Functions
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Window function explanation&lt;/em&gt;&lt;br&gt;
An average calculation like AVG(total_bill_kes) along with GROUP BY combines multiple rows into a single row for each group. A window function does a similar calculation but retains each original row, adding the result as a new column. It responds to the question “how does this visit’s bill compare with its department’s average?” without loosing the visit itself.&lt;/p&gt;

&lt;p&gt;Group by: one rows per department&lt;br&gt;
select department_id,avg(total_bill_kes) as avg_bill&lt;br&gt;
from visits_fact&lt;br&gt;
Group by department_id;&lt;/p&gt;

&lt;p&gt;select visit_id,department_id,total_bill_kes,&lt;br&gt;
Avg(totall_bill_kes) Over (partition by department_id) as department_avg_bill&lt;br&gt;
From visits_facts;&lt;/p&gt;

&lt;p&gt;The first query gives one row for each department. The second one brings back every visit, and each row shows the average for its department.&lt;br&gt;
The anatomy of a window: OVER, PARTITION BY, ORDER BY and frames.&lt;/p&gt;

&lt;p&gt;function_name(...)over(&lt;br&gt;
partition by column_list&lt;br&gt;
order by column_list&lt;br&gt;
rows between -- and --&lt;/p&gt;

&lt;p&gt;OVER() is what changes a regular function into a window function. It determines the range of rows that the function is able to view. An empty OVER () means the window is the entire result set.&lt;br&gt;
Partition by splits rows into separate groups, similar to group by, but it doesn't combine the rows into a single result. The function restarts for each partition. Partitioning by department_id means that each department is ranked or summed up individually.&lt;br&gt;
ORDER BY, which is used inside OVER, determines the order of rows within each group or partition. It is needed for ranking functions and for LAG and LEAD, and when used with aggregates, it helps create running calculations. It is different from the ORDER BY clause at the end of the query, which is used to sort the final result.&lt;br&gt;
Window frames limit the window to a sliding set of rows around the current row. The frame is defined as ROWS BETWEEN AND , with the boundaries being:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;unbounded preceding -the first row of the partition&lt;/li&gt;
&lt;li&gt;n preceding - n rows before the current row&lt;/li&gt;
&lt;li&gt;current row - the current row&lt;/li&gt;
&lt;li&gt;n following - n rows after the current row&lt;/li&gt;
&lt;li&gt;unbounded following - the last row of partition&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Two frame facts&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;ROWS vs RANGE. ROWS counts physical rows. RANGE treats rows that have the same ORDER BY values (peers) as a single group, so rows that are tied are all included together.&lt;/li&gt;
&lt;li&gt;The default frame. When a window has an ORDER BY clause but doesn't specify a frame, SQL uses the RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW by default. This is why a running total works automatically, and it's also why LAST_VALUE behaves incorrectly by default 
### Ranking functions: ROW_NUMBER, RANK, DENSE_RANK and NTILE&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ranking functions assign a position to each row within its partition. They are different only in the way they handle ties.&lt;/p&gt;

&lt;p&gt;Scenario: The management wishes to sort doctors based on the amount of revenue they generate within each department.&lt;br&gt;
SELECT d.department_name,&lt;br&gt;
       doc.doctor_name,&lt;br&gt;
       SUM(f.total_bill_kes) AS total_billed,&lt;br&gt;
       ROW_NUMBER() OVER (PARTITION BY d.department_name ORDER BY SUM(f.total_bill_kes) DESC) AS row_num,&lt;br&gt;
       RANK()       OVER (PARTITION BY d.department_name ORDER BY SUM(f.total_bill_kes) DESC) AS rnk,&lt;br&gt;
       DENSE_RANK() OVER (PARTITION BY d.department_name ORDER BY SUM(f.total_bill_kes) DESC) AS dense_rnk&lt;br&gt;
FROM visits_fact f&lt;br&gt;
JOIN dim_doctors     doc ON doc.doctor_id = f.doctor_id&lt;br&gt;
JOIN dim_departments d   ON d.department_id = f.department_id&lt;br&gt;
GROUP BY d.department_name, doc.doctor_name&lt;br&gt;
ORDER BY d.department_name, total_billed DESC;&lt;/p&gt;

&lt;p&gt;What it does: GROUP BY first calculates the total billing for each doctor within each department. The window functions then operate on those totals, starting fresh in each department (PARTITION BY) and sorting the highest total first. All three ranking functions are shown together so you can compare how ties are handled.&lt;/p&gt;

&lt;p&gt;SELECT visit_id,&lt;br&gt;
       total_bill_kes,&lt;br&gt;
       NTILE(4) OVER (ORDER BY total_bill_kes DESC) AS cost_quartile&lt;br&gt;
FROM visits_fact;&lt;br&gt;
It organizes all visits by cost, starting with the most expensive and ending with the least, then divides the list into four equal parts. Quartile 1 includes the most expensive visits, while quartile 4 has the least costly ones. If the number of rows doesn't split evenly into the buckets, like 10 rows into 4 buckets, the first few buckets get an extra row.For example, it would be 3, 3, 2, 2.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Department name&lt;/th&gt;
&lt;th&gt;Doctor_name&lt;/th&gt;
&lt;th&gt;totall_billed&lt;/th&gt;
&lt;th&gt;row number&lt;/th&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Dense_rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cardiology&lt;/td&gt;
&lt;td&gt;Dr.Otieno&lt;/td&gt;
&lt;td&gt;900000&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cardiology&lt;/td&gt;
&lt;td&gt;Dr.Wanjiru&lt;/td&gt;
&lt;td&gt;900000&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cardiology&lt;/td&gt;
&lt;td&gt;Dr.Kamau&lt;/td&gt;
&lt;td&gt;700000&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cardiology&lt;/td&gt;
&lt;td&gt;Dr.Achieng&lt;/td&gt;
&lt;td&gt;500000&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Otieno and Wanjiru tie. ROW_NUMBER still separates them (1 and 2). RANK starts at 1 and then goes to 3. DENSE_RANK assigns a 1 to both and then continues with 2.&lt;br&gt;
When to use which: use ROW_NUMBER to select exactly one row from each group, such as one row per patient.&lt;br&gt;
most recent visit, or removing duplicate records). Use RANK to show competition-style rankings where there can be gaps between positions. Use DENSE_RANK for “top N levels” questions.&lt;br&gt;
Top-N per group. Window functions cannot be used with filters in the WHERE clause, so you should put the query inside a common table expression.&lt;br&gt;
WITH doctor_revenue AS (&lt;br&gt;
    SELECT d.department_name, doc.doctor_name,&lt;br&gt;
           SUM(f.total_bill_kes) AS total_billed,&lt;br&gt;
           DENSE_RANK() OVER (PARTITION BY d.department_name ORDER BY SUM(f.total_bill_kes) DESC) AS dr&lt;br&gt;
    FROM visits_fact f&lt;br&gt;
    JOIN dim_doctors     doc ON doc.doctor_id = f.doctor_id&lt;br&gt;
    JOIN dim_departments d   ON d.department_id = f.department_id&lt;br&gt;
    GROUP BY d.department_name, doc.doctor_name&lt;br&gt;
)&lt;br&gt;
SELECT department_name, doctor_name, total_billed&lt;br&gt;
FROM doctor_revenue&lt;br&gt;
WHERE dr &amp;lt;= 2;&lt;/p&gt;

&lt;p&gt;This shows the top two billing levels in each department, including all doctors who are tied at those levels.&lt;br&gt;
NTILE(n)&lt;br&gt;
NTILE(n) splits the ordered rows in a group into n nearly equal parts and assigns each row a number based on which part it belongs to. It is used for quartiles, deciles, and other types of segmentation.&lt;br&gt;
Scenario: The finance department wants to divide visits into four cost groups to identify the top 25% that are the most expensive.&lt;br&gt;
Expected result (sample):&lt;br&gt;
| visit_id | Total_bill_kes | Cost_quartile |&lt;br&gt;
| ---|---|---:|&lt;br&gt;
| 4120 | 980000 | 1 |&lt;br&gt;
| 3771 | 760000 | 1 |&lt;br&gt;
| 3050 | 410000 | 2 |&lt;br&gt;
| 1204 | 9500   | 4 |&lt;/p&gt;

&lt;h3&gt;
  
  
  Offset functions: LAG and LEAD
&lt;/h3&gt;

&lt;p&gt;LAG(column, offset, default) retrieves a value from a row that comes before the current row within the same partition. LEAD reads data from a row that comes after the current row, with an option to specify how many rows to skip and a default value if no data is found. The offset starts at 1, and if no row is found, the default value is set to NULL. They eliminate the need to join a table to itself in order to compare consecutive records.&lt;br&gt;
Scenario (LAG): The quality team monitors readmissions, which are cases where patients come back to the hospital shortly after they were discharged. For every visit, we need the gap because that patient's previous stay has already ended. A stay is considered to begin on the visit date and continue for the number of days specified as the length of stay.&lt;/p&gt;

&lt;p&gt;SELECT patient_id,&lt;br&gt;
       visit_id,&lt;br&gt;
       visit_date,&lt;br&gt;
       LAG(visit_date + length_of_stay_days)&lt;br&gt;
           OVER (PARTITION BY patient_id ORDER BY visit_date) AS previous_discharge,&lt;br&gt;
       visit_date - LAG(visit_date + length_of_stay_days)&lt;br&gt;
           OVER (PARTITION BY patient_id ORDER BY visit_date) AS days_since_last_stay&lt;br&gt;
FROM visits_fact;&lt;/p&gt;

&lt;p&gt;For each patient, the visits are arranged in order based on the date. LAG(...) gets the end date of the patient's last stay, and by taking the current visit date and subtracting that end date, it calculates the number of days between them. PostgreSQL gives an integer when you subtract dates; in MySQL, you can use DATEDIFF(visit_date, LAG(...)), and in SQL Server, you use DATEDIFF(day, LAG(...), visit_date), and if you need to add days, you can use DATEADD.&lt;br&gt;
The first visit of each patient does not have an earlier stay, so it is shown as NULL. Wrapping the query in a CTE and filtering based on days since the last stay.&lt;/p&gt;

&lt;p&gt;SELECT patient_id,&lt;br&gt;
       visit_date,&lt;br&gt;
       LEAD(visit_date) OVER (PARTITION BY patient_id ORDER BY visit_date) AS next_visit_date&lt;br&gt;
FROM visits_fact;&lt;/p&gt;

&lt;p&gt;The expected result is that each row displays a visit date along with the date of the patient’s next visit, and for the patient’s last visit, it shows NULL. LAG looks back and LEAD looks ahead; besides the direction, they are the same.&lt;/p&gt;

&lt;h4&gt;
  
  
  FIRST_VALUE and LAST_VALUE
&lt;/h4&gt;

&lt;p&gt;FIRST_VALUE(col) gets the value from the first row in the window frame, and LAST_VALUE(col) gets the value from the last row. They help in comparing every row with a beginning point or an ending point.&lt;br&gt;
For each visit, display the patient's first and latest diagnosis, allowing clinicians to understand the patient's medical journey from the beginning up to the present.&lt;/p&gt;

&lt;p&gt;SELECT f.patient_id,&lt;br&gt;
       f.visit_date,&lt;br&gt;
       dg.diagnosis_name,&lt;br&gt;
       FIRST_VALUE(dg.diagnosis_name) OVER (&lt;br&gt;
           PARTITION BY f.patient_id ORDER BY f.visit_date&lt;br&gt;
       ) AS first_diagnosis,&lt;br&gt;
       LAST_VALUE(dg.diagnosis_name) OVER (&lt;br&gt;
           PARTITION BY f.patient_id ORDER BY f.visit_date&lt;br&gt;
           ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING&lt;br&gt;
       ) AS latest_diagnosis&lt;br&gt;
FROM visits_fact f&lt;br&gt;
JOIN dim_diagnosis dg ON dg.diagnosis_id = f.diagnosis_id;&lt;/p&gt;

&lt;p&gt;What it does: FIRST_VALUE uses the default frame since the first row in a partition is always part of it. LAST_VALUE requires a clearly defined frame that extends to UNBOUNDED FOLLOWING.&lt;br&gt;
The classic LAST_VALUE trap. With the default frame, which stops at the current row, LAST_VALUE just gives back the value from the current row, making it seem like the function isn't working properly. Always include ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to correctly identify the actual last row within the partition.&lt;br&gt;
Expected result (sample, patient 101):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;patient_id&lt;/th&gt;
&lt;th&gt;visit_date&lt;/th&gt;
&lt;th&gt;Diagnosis_name&lt;/th&gt;
&lt;th&gt;First_diagnosis&lt;/th&gt;
&lt;th&gt;latest_diagnosis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;2026-01-05&lt;/td&gt;
&lt;td&gt;Hypertension&lt;/td&gt;
&lt;td&gt;Hypertension&lt;/td&gt;
&lt;td&gt;Malaria&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;2026-01-25&lt;/td&gt;
&lt;td&gt;Pneumonia&lt;/td&gt;
&lt;td&gt;Hypertension&lt;/td&gt;
&lt;td&gt;Malaria&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;2026-06-02&lt;/td&gt;
&lt;td&gt;Malaria&lt;/td&gt;
&lt;td&gt;Hypertension&lt;/td&gt;
&lt;td&gt;Malaria&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Aggregate window functions include SUM, AVG, COUNT, MIN, and MAX.These functions are used to perform calculations across a set of rows that are related to the current row.They help in analyzing data by providing summary statistics within a specific window of data.&lt;br&gt;
The familiar aggregates become window functions when they are followed by OVER. Three patterns cover most real-world uses:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Whole-partition value (OVER (PARTITION BY ...) with no ORDER BY): each row sees the total, average, count, minimum, or maximum of the entire group.&lt;/li&gt;
&lt;li&gt;Running calculation (ORDER BY added): the aggregate adds up each row one at a time.&lt;/li&gt;
&lt;li&gt;Moving calculation, which uses an explicit window of rows between a certain number of preceding rows and the current row, is known as a sliding window.
Scenario A: whole-partition statistics. Compare every visit with its department’s profile.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SELECT visit_id,&lt;br&gt;
       department_id,&lt;br&gt;
       total_bill_kes,&lt;br&gt;
       COUNT(*)            OVER (PARTITION BY department_id) AS dept_visits,&lt;br&gt;
       SUM(total_bill_kes) OVER (PARTITION BY department_id) AS dept_total_billed,&lt;br&gt;
       ROUND(AVG(total_bill_kes) OVER (PARTITION BY department_id), 2) AS dept_avg_bill,&lt;br&gt;
       MIN(total_bill_kes) OVER (PARTITION BY department_id) AS dept_min_bill,&lt;br&gt;
       MAX(total_bill_kes) OVER (PARTITION BY department_id) AS dept_max_bill,&lt;br&gt;
       total_bill_kes - AVG(total_bill_kes) OVER (PARTITION BY department_id) AS diff_from_avg&lt;br&gt;
FROM visits_fact;&lt;/p&gt;

&lt;p&gt;Each time you visit, it keeps a separate bill and displays five statistics at the department level. The final column indicates whether a visit is more (positive) or less (negative) than the average for its department, highlighting visits that are unusually costly and not connected through a join or GROUP BY clause.&lt;/p&gt;

&lt;h4&gt;
  
  
  Window functions vs GROUP BY, and a few rules
&lt;/h4&gt;

&lt;p&gt;GROUP BY&lt;br&gt;
Window function&lt;br&gt;
Rows returned&lt;br&gt;
One per group&lt;br&gt;
One per original row&lt;br&gt;
Detail columns&lt;br&gt;
Lost unless grouped&lt;br&gt;
Fully retained&lt;br&gt;
Best for&lt;br&gt;
Summary reports&lt;br&gt;
Rankings, running totals, comparisons with a group&lt;br&gt;
Three important rules to keep in mind: window functions can only be used in the SELECT clause or the ORDER BY clause, so you should use a CTE or a subquery to filter them first; they are processed after the WHERE, GROUP BY, and HAVING clauses; and ranking functions require an ORDER BY statement inside the OVER clause to work properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQL Joins
&lt;/h2&gt;

&lt;p&gt;A join combines rows from two tables by matching a join condition, which is usually a foreign key to a primary key. In this star schema, the data is intentionally split into different tables, like visits stored in visits_fact and patient information in dim_patients, so you have to use joins to put everything back together and see the complete picture.&lt;br&gt;
To make the differences clearer, imagine these small examples:&lt;br&gt;
dim_patients: (1, Wanjiku, Nairobi), (2, Otieno, Kisumu), (3, Halima, Mombasa), (4, Kiprono, Nakuru)&lt;br&gt;
Visits fact: (V1, patient 1, Cardiology), (V2, patient 1, Orthopaedics), (V3, patient 2, Paediatrics), (V4, patient 99, Cardiology)&lt;br&gt;
Halima, patient 3, and Kiprono, patient 4, have never had any visits recorded.The visit V4 is linked to patient 99, who doesn't exist, which is due to a data entry mistake that created an orphan record.&lt;/p&gt;

&lt;h3&gt;
  
  
  INNER JOIN
&lt;/h3&gt;

&lt;p&gt;Returns only the rows that have a match in both tables. Unmatched rows on either side disappear.&lt;br&gt;
SELECT p.county, p.patient_gender, f.visit_id, f.total_bill_kes&lt;br&gt;
FROM visits_fact f&lt;br&gt;
INNER JOIN dim_patients p ON p.patient_id = f.patient_id;&lt;br&gt;
The results are based on the samples: V1, V2 (Wanjiku), and V3 (Otieno). Halima and Kiprono disappear because they have no visits, and V4 disappears because it has no patient.&lt;br&gt;
Use this when you only want records that are present on both sides, like "total billing by county," because a visit without a known patient can't be assigned to a county in the first place. A standard star-schema report connects multiple inner joins from the fact table.&lt;br&gt;
SELECT d.department_name, doc.doctor_name, p.county, f.total_bill_kes&lt;br&gt;
FROM visits_fact f&lt;br&gt;
JOIN dim_departments d ON d.department_id = f.department_id&lt;br&gt;
JOIN dim_doctors doc ON doc.doctor_id = f.doctor_id&lt;br&gt;
JOIN dim_patients p ON p.patient_id = f.patient_id;&lt;br&gt;
This provides one readable row for each visit, including the names of the department, doctor, and county.(Joining on its own refers to an INNER JOIN.)&lt;/p&gt;

&lt;h3&gt;
  
  
  LEFT JOIN (LEFT OUTER JOIN)
&lt;/h3&gt;

&lt;p&gt;Returns every row from the left table along with any matching rows from the right table. When there's no match, the columns on the right side will show as NULL.&lt;br&gt;
SELECT p.patient_id, p.county, f.visit_id&lt;br&gt;
FROM dim_patients p&lt;br&gt;
LEFT JOIN visits_fact f ON f.patient_id = p.patient_id;&lt;br&gt;
Anti-join pattern. Add WHERE f.visit_id IS NULL to keep only the unmatched left rows. This answers “which registered patients have never had a visit?”:&lt;/p&gt;

&lt;p&gt;SELECT p.patient_id, p.county&lt;br&gt;
FROM dim_patients p&lt;br&gt;
LEFT JOIN visits_fact f ON f.patient_id = p.patient_id&lt;br&gt;
WHERE f.visit_id IS NULL;&lt;/p&gt;

&lt;h3&gt;
  
  
  RIGHT JOIN (RIGHT OUTER JOIN)
&lt;/h3&gt;

&lt;p&gt;The mirror image: all rows from the right table, with matching left rows or NULLs.&lt;/p&gt;

&lt;p&gt;SELECT p.county, f.visit_id, f.total_bill_kes&lt;br&gt;
FROM dim_patients p&lt;br&gt;
RIGHT JOIN visits_fact f ON f.patient_id = p.patient_id;&lt;/p&gt;

&lt;h3&gt;
  
  
  FULL OUTER JOIN
&lt;/h3&gt;

&lt;p&gt;Returns all rows from both tables, matching where possible and filling NULLs on whichever side lacks a partner.&lt;/p&gt;

&lt;p&gt;SELECT p.patient_id AS dim_patient_id,&lt;br&gt;
       f.patient_id AS fact_patient_id,&lt;br&gt;
       f.visit_id&lt;br&gt;
FROM dim_patients p&lt;br&gt;
FULL OUTER JOIN visits_fact f ON f.patient_id = p.patient_id; &lt;/p&gt;

&lt;h3&gt;
  
  
  CROSS JOIN
&lt;/h3&gt;

&lt;p&gt;Returns the Cartesian product by combining each row from the first table with every row from the second table, without any specific condition for matching rows. Five departments and two genders result in ten different rows.&lt;/p&gt;

&lt;p&gt;SELECT d.department_name, g.patient_gender, COUNT(f.visit_id) AS visits&lt;br&gt;
FROM dim_departments d&lt;br&gt;
CROSS JOIN (SELECT DISTINCT patient_gender FROM dim_patients) g&lt;br&gt;
LEFT JOIN dim_patients p ON p.patient_gender = g.patient_gender&lt;br&gt;
LEFT JOIN visits_fact  f ON f.patient_id = p.patient_id&lt;br&gt;
                        AND f.department_id = d.department_id&lt;br&gt;
GROUP BY d.department_name, g.patient_gender&lt;br&gt;
ORDER BY d.department_name, g.patient_gender;&lt;/p&gt;

&lt;p&gt;The cross join builds a complete department-by-gender grid first. The LEFT JOINs then attach the actual visits, so a combination with no visits still appears with a count of 0 instead of being missing. This “report skeleton” use is the most practical reason to cross join. Use it deliberately, because crossing two large tables produces enormous results.&lt;/p&gt;

&lt;h3&gt;
  
  
  SELF JOIN
&lt;/h3&gt;

&lt;p&gt;Not a separate keyword but a technique: a table joined to itself under two aliases. It suits comparisons between rows of the same table. The hospital tables have no supervisor column, so the natural self join here is on the fact table, to find repeat visits by the same patient within 30 days.&lt;/p&gt;

&lt;p&gt;SELECT a.patient_id,&lt;br&gt;
       a.visit_id   AS first_visit,&lt;br&gt;
       a.visit_date AS first_date,&lt;br&gt;
       b.visit_id   AS repeat_visit,&lt;br&gt;
       b.visit_date AS repeat_date,&lt;br&gt;
       b.visit_date - a.visit_date AS days_apart&lt;br&gt;
FROM visits_fact a&lt;br&gt;
JOIN visits_fact b&lt;br&gt;
  ON  b.patient_id = a.patient_id&lt;br&gt;
  AND b.visit_date &amp;gt; a.visit_date&lt;br&gt;
  AND b.visit_date &amp;lt;= a.visit_date + 30&lt;br&gt;
ORDER BY a.patient_id, a.visit_date;&lt;/p&gt;

&lt;p&gt;Alias A represents the earlier visit, and alias B represents the later visit by the same patient. The condition b.visit_date &amp;gt; a.visit_date prevents a visit from being matched with itself and ensures each pair is not reported twice, and + 30 limits the time difference to 30 days. Every row returned is a repeat visit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Table Expressions (CTEs)
&lt;/h2&gt;

&lt;p&gt;CTEs is a  common whose table expression is a named, temporary set of results that is created at the beginning of a query using the WITH keyword. It lasts only as long as that single statement is made. Think of it as giving a name to an intermediate step so the main query can use it just like a table. Common Table Expressions (CTEs) are important because they improve the readability of complex queries by organizing steps in a top-to-bottom flow rather than having nested structures.They also allow for reusability, as a single CTE can be used multiple times within a query.Additionally, CTEs make debugging easier since each step can be executed separately to test and verify its functionality.&lt;/p&gt;

&lt;p&gt;WITH cte_name AS (&lt;br&gt;
    SELECT ...&lt;br&gt;
)&lt;br&gt;
SELECT ... FROM cte_name;&lt;/p&gt;

&lt;h3&gt;
  
  
  Basic CTE
&lt;/h3&gt;

&lt;p&gt;Scenario: Find departments whose average bill per visit is above the hospital-wide average.&lt;/p&gt;

&lt;p&gt;WITH dept_avg AS (&lt;br&gt;
    SELECT department_id,&lt;br&gt;
           AVG(total_bill_kes) AS avg_bill&lt;br&gt;
    FROM visits_fact&lt;br&gt;
    GROUP BY department_id&lt;br&gt;
)&lt;br&gt;
SELECT d.department_name, ROUND(da.avg_bill, 2) AS avg_bill&lt;br&gt;
FROM dept_avg da&lt;br&gt;
JOIN dim_departments d ON d.department_id = da.department_id&lt;br&gt;
WHERE da.avg_bill &amp;gt; (SELECT AVG(total_bill_kes) FROM visits_fact)&lt;br&gt;
ORDER BY da.avg_bill DESC;&lt;/p&gt;

&lt;p&gt;What it does: The CTE dept_avg calculates one average for each department. The main query connects that small result with dim_departments to get the department names in a readable format, then filters to include only those departments with bills higher than the overall average. Without the CTE, you would have to repeat the AVG in a HAVING clause or nest the query inside another one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multiple CTEs
&lt;/h3&gt;

&lt;p&gt;Several CTEs can be chained under a single WITH, separated by commas. Each can refer to the ones defined before it, so a complex analysis reads like a sequence of steps.&lt;br&gt;
Scenario: For each county, compare total billing with average length of stay, and show each county’s share of total revenue.&lt;/p&gt;

&lt;p&gt;WITH county_revenue AS (&lt;br&gt;
    SELECT p.county,&lt;br&gt;
           COUNT(*)              AS visits,&lt;br&gt;
           SUM(f.total_bill_kes) AS total_billed&lt;br&gt;
    FROM visits_fact f&lt;br&gt;
    JOIN dim_patients p ON p.patient_id = f.patient_id&lt;br&gt;
    GROUP BY p.county&lt;br&gt;
),&lt;br&gt;
county_stay AS (&lt;br&gt;
    SELECT p.county,&lt;br&gt;
           ROUND(AVG(f.length_of_stay_days), 1) AS avg_stay_days&lt;br&gt;
    FROM visits_fact f&lt;br&gt;
    JOIN dim_patients p ON p.patient_id = f.patient_id&lt;br&gt;
    GROUP BY p.county&lt;br&gt;
),&lt;br&gt;
grand_total AS (&lt;br&gt;
    SELECT SUM(total_billed) AS all_billed FROM county_revenue&lt;br&gt;
)&lt;br&gt;
SELECT r.county,&lt;br&gt;
       r.visits,&lt;br&gt;
       r.total_billed,&lt;br&gt;
       s.avg_stay_days,&lt;br&gt;
       ROUND(100.0 * r.total_billed / g.all_billed, 1) AS pct_of_revenue&lt;br&gt;
FROM county_revenue r&lt;br&gt;
JOIN county_stay s ON s.county = r.county&lt;br&gt;
CROSS JOIN grand_total g&lt;br&gt;
ORDER BY r.total_billed DESC;&lt;/p&gt;

&lt;p&gt;What it does: county_revenue and county_stay each capture one specific aspect for each county.grand_total is based on county_revenue, which is a CTE that refers back to an earlier CTE. The last query combines them and uses the overall total to calculate each county's share as a percentage. A single-row CTE like grand_total is a clean method to include a single number in every row.&lt;/p&gt;

&lt;h3&gt;
  
  
  Common Table Expressions with joins, aggregations, and window functions.
&lt;/h3&gt;

&lt;p&gt;Common Table Expressions (CTEs) are the standard method for filtering within a window function, and they cannot be used in the WHERE clause. This example identifies each patient's latest visit, including the doctor and the department involved.&lt;/p&gt;

&lt;p&gt;WITH numbered AS (&lt;br&gt;
    SELECT f.patient_id,&lt;br&gt;
           f.visit_date,&lt;br&gt;
           f.doctor_id,&lt;br&gt;
           f.department_id,&lt;br&gt;
           f.total_bill_kes,&lt;br&gt;
           ROW_NUMBER() OVER (PARTITION BY f.patient_id ORDER BY f.visit_date DESC) AS rn&lt;br&gt;
    FROM visits_fact f&lt;br&gt;
)&lt;br&gt;
SELECT p.patient_id, p.county, n.visit_date AS last_visit,&lt;br&gt;
       doc.doctor_name, d.department_name, n.total_bill_kes&lt;br&gt;
FROM numbered n&lt;br&gt;
JOIN dim_patients    p   ON p.patient_id    = n.patient_id&lt;br&gt;
JOIN dim_doctors     doc ON doc.doctor_id    = n.doctor_id&lt;br&gt;
JOIN dim_departments d   ON d.department_id  = n.department_id&lt;br&gt;
WHERE n.rn = 1;&lt;br&gt;
What it does: The CTE lists each patient's visits starting with the most recent one and moving to the oldest. The main query retains only the most recent visit, where rn equals 1, and incorporates the descriptive names through joins. The result shows one row for each patient who has ever been visited.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recursive CTEs
&lt;/h3&gt;

&lt;p&gt;A recursive CTE references itself. It is built for data that is organized in a hierarchy or sequence, and it has two sections connected by UNION ALL.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Anchor member: the starting rows (runs once).&lt;/li&gt;
&lt;li&gt;A recursive member is a query that uses the results from the CTE itself to generate the next set of rows, and this process keeps repeating until there are no more rows to return.
In PostgreSQL and MySQL, the keyword is WITH RECURSIVE; in SQL Server, it is simply WITH.
The hospital tables don't have a parent-child structure like a doctor-to-supervisor hierarchy, so the best recursive CTE to use here creates a continuous list of dates. This solves an actual reporting issue: days with no visits are not included in the GROUP BY visit_date, which affects trends and the 7-day moving average in Section 1.6.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;WITH RECURSIVE calendar AS (&lt;br&gt;
    -- Anchor: the first date in the data&lt;br&gt;
    SELECT MIN(visit_date)::date AS day&lt;br&gt;
    FROM visits_fact&lt;br&gt;
SELECT c.day,&lt;br&gt;
       COUNT(f.visit_id)                 AS visits,&lt;br&gt;
       COALESCE(SUM(f.total_bill_kes), 0) AS revenue&lt;br&gt;
FROM calendar c&lt;br&gt;
LEFT JOIN visits_fact f ON f.visit_date::date = c.day&lt;br&gt;
GROUP BY c.day&lt;br&gt;
ORDER BY c.day;&lt;/p&gt;

&lt;p&gt;What it does: The anchor returns the earliest visit date. Each recursion adds one day, stopping when the latest visit date is reached. The calendar is then LEFT JOINed to the visits, so a quiet day shows 0 visits and 0 revenue instead of disappearing. (In MySQL use day + INTERVAL 1 DAY; in SQL Server use DATEADD(day, 1, day).)&lt;/p&gt;

&lt;h3&gt;
  
  
  CTEs vs subqueries
&lt;/h3&gt;

&lt;p&gt;Both let you use the result of one query inside another, and for simple needs they give the same answer. The differences are about structure and capability.&lt;br&gt;
The same question written both ways, “visits that cost more than the hospital average”:&lt;/p&gt;

&lt;p&gt;SELECT visit_id, total_bill_kes&lt;br&gt;
FROM visits_fact&lt;br&gt;
WHERE total_bill_kes &amp;gt; (SELECT AVG(total_bill_kes) FROM visits_fact);&lt;/p&gt;

&lt;p&gt;-- CTE&lt;br&gt;
WITH avg_bill AS (SELECT AVG(total_bill_kes) AS overall_avg FROM visits_fact)&lt;br&gt;
SELECT f.visit_id, f.total_bill_kes&lt;br&gt;
FROM visits_fact f&lt;br&gt;
CROSS JOIN avg_bill a&lt;br&gt;
WHERE f.total_bill_kes &amp;gt; a.overall_avg;&lt;/p&gt;

&lt;p&gt;What it does: The CTE lists each patient's visits starting with the most recent one and moving to the oldest. The main query retains only the most recent visit, where rn equals 1, and incorporates the descriptive names through joins. The result shows one row for each patient who has ever been visited.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recursive CTEs
&lt;/h3&gt;

&lt;p&gt;A recursive CTE references itself. It is built for data that is organized in a hierarchy or sequence, and it has two sections connected by UNION ALL.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Anchor member: the starting rows (runs once).&lt;/li&gt;
&lt;li&gt;A recursive member is a query that uses the results from the CTE itself to generate the next set of rows, and this process keeps repeating until there are no more rows to return.
In PostgreSQL and MySQL, the keyword is WITH RECURSIVE; in SQL Server, it is simply WITH.
The hospital tables don't have a parent-child structure like a doctor-to-supervisor hierarchy, so the best recursive CTE to use here creates a continuous list of dates. This solves an actual reporting issue: days with no visits are not included in the GROUP BY visit_date, which affects trends and the 7-day moving average in Section 1.6.
### SQL Subqueries
A subquery is a SELECT statement that is placed inside another SQL statement. The inner query executes, and its outcome is used by the outer query. Subqueries allow you to answer questions that rely on the result of another question.For example, to find out which visits cost more than the average, you first need to know what the average cost is.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Scalar subqueries
&lt;/h3&gt;

&lt;p&gt;A scalar subquery gives back just one row and one column, which is a single value, so it can be used in any place where a single value is needed. If the query brings back more than one row, the database will show an error.&lt;br&gt;
Scenario: Show all visits where the cost is higher than the average bill for the entire hospital.&lt;/p&gt;

&lt;p&gt;SELECT visit_id, patient_id, total_bill_kes&lt;br&gt;
FROM visits_fact&lt;br&gt;
WHERE total_bill_kes &amp;gt; (SELECT AVG(total_bill_kes) FROM visits_fact);&lt;/p&gt;

&lt;p&gt;What it does: The inner query returns one number (say 21,500). The outer query keeps only visits above it. The same idea works in the SELECT list:&lt;br&gt;
SELECT visit_id,&lt;br&gt;
       total_bill_kes,&lt;br&gt;
       total_bill_kes - (SELECT AVG(total_bill_kes) FROM visits_fact) AS diff_from_hospital_avg&lt;br&gt;
FROM visits_fact;&lt;/p&gt;

&lt;h3&gt;
  
  
  Non-correlated (independent) subqueries
&lt;/h3&gt;

&lt;p&gt;A subquery that isn't related can work by itself; it doesn't need to look at the main query. It runs once and the result is used again. The scalar examples above are non-correlated,&lt;/p&gt;

&lt;h3&gt;
  
  
  Correlated subqueries
&lt;/h3&gt;

&lt;p&gt;A correlated subquery refers to a column of the outer query, so it is logically re-evaluated once per outer row. It cannot run on its own.&lt;/p&gt;

&lt;p&gt;Example: Find visits that cost more than the average for their own department.&lt;/p&gt;

&lt;p&gt;SELECT f.visit_id, f.department_id, f.total_bill_kes&lt;br&gt;
FROM visits_fact f&lt;br&gt;
WHERE f.total_bill_kes &amp;gt; (&lt;br&gt;
    SELECT AVG(f2.total_bill_kes)&lt;br&gt;
    FROM visits_fact f2&lt;br&gt;
    WHERE f2.department_id = f.department_id   -- correlation with the outer row&lt;br&gt;
);&lt;/p&gt;

&lt;h3&gt;
  
  
  Nested subqueries
&lt;/h3&gt;

&lt;p&gt;Subqueries can be placed inside other subqueries to any depth. Scenario: Find patients who were treated in the department with the highest total billing.&lt;/p&gt;

&lt;p&gt;SELECT patient_id, county&lt;br&gt;
FROM dim_patients&lt;br&gt;
WHERE patient_id IN (&lt;br&gt;
    SELECT patient_id&lt;br&gt;
    FROM visits_fact&lt;br&gt;
    WHERE department_id = (&lt;br&gt;
        SELECT department_id&lt;br&gt;
        FROM visits_fact&lt;br&gt;
        GROUP BY department_id&lt;br&gt;
        ORDER BY SUM(total_bill_kes) DESC&lt;br&gt;
        LIMIT 1&lt;br&gt;
    )&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;What it does, from the inside out: first, it identifies the department that has the highest total billing; then, it lists all the patients who have visited that department; finally, it provides the details of those patients. It works, but reading through three levels already requires some effort, and that's when using a CTE or a join would be a better option.(LIMIT 1 is used in PostgreSQL and MySQL; SQL Server uses TOP 1 instead.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Subqueries with IN, EXISTS, ANY and ALL
&lt;/h3&gt;

&lt;p&gt;IN checks if a value is found in the list of results from the subquery, as shown in section 4.2.&lt;br&gt;
EXISTS returns TRUE when the subquery has at least one row; it only checks if a match is present, not what the actual values are. It often goes along with that and stops at the first match.&lt;/p&gt;

&lt;p&gt;-- Patients who have at least one visit longer than 7 days&lt;br&gt;
SELECT p.patient_id, p.county&lt;br&gt;
FROM dim_patients p&lt;br&gt;
WHERE EXISTS (&lt;br&gt;
    SELECT 1&lt;br&gt;
    FROM visits_fact f&lt;br&gt;
    WHERE f.patient_id = p.patient_id&lt;br&gt;
      AND f.length_of_stay_days &amp;gt; 7&lt;br&gt;
);&lt;br&gt;
The use of SELECT 1 is a common practice, as the actual value being selected does not matter. NOT EXISTS reverses the test and identifies patients who do not have such a visit.&lt;br&gt;
IN vs EXISTS, and the NOT IN trap. For positive checks, the two usually bring back the same rows. Using NOT IN with a subquery that might return NULL can be risky.If the subquery gives a NULL, the comparison becomes unknown, and the entire query ends up returning no rows. "NOT EXISTS is safe when dealing with NULLs, so it's better to use it for questions about things that do not exist."&lt;/p&gt;

&lt;p&gt;-- Patients who never had a visit (safe even if visits_fact.patient_id contains NULLs)&lt;br&gt;
SELECT p.patient_id, p.county&lt;br&gt;
FROM dim_patients p&lt;br&gt;
WHERE NOT EXISTS (SELECT 1 FROM visits_fact f WHERE f.patient_id = p.patient_id);&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting It All Together: A Real-World Hospital Question
&lt;/h3&gt;

&lt;p&gt;Question: Which visits were readmissions within 30 days of the patient’s previous discharge, in which county and department, and how does each bill compare with its department average?&lt;/p&gt;

&lt;p&gt;WITH stays AS (&lt;br&gt;
    SELECT patient_id, visit_id, department_id, visit_date, total_bill_kes,&lt;br&gt;
           LAG(visit_date + length_of_stay_days)&lt;br&gt;
               OVER (PARTITION BY patient_id ORDER BY visit_date) AS prev_discharge&lt;br&gt;
    FROM visits_fact&lt;br&gt;
),&lt;br&gt;
readmits AS (&lt;br&gt;
    SELECT *, visit_date - prev_discharge AS gap_days&lt;br&gt;
    FROM stays&lt;br&gt;
    WHERE prev_discharge IS NOT NULL&lt;br&gt;
      AND visit_date - prev_discharge &amp;lt;= 30&lt;br&gt;
),&lt;br&gt;
dept_avg AS (&lt;br&gt;
    SELECT department_id, AVG(total_bill_kes) AS avg_bill&lt;br&gt;
    FROM visits_fact&lt;br&gt;
    GROUP BY department_id&lt;br&gt;
)&lt;br&gt;
SELECT p.county,&lt;br&gt;
       d.department_name,&lt;br&gt;
       r.visit_id,&lt;br&gt;
       r.gap_days,&lt;br&gt;
       r.total_bill_kes,&lt;br&gt;
       ROUND(r.total_bill_kes - da.avg_bill, 2) AS vs_dept_avg&lt;br&gt;
FROM readmits r&lt;br&gt;
JOIN dim_patients    p  ON p.patient_id    = r.patient_id&lt;br&gt;
JOIN dim_departments d  ON d.department_id = r.department_id&lt;br&gt;
JOIN dept_avg        da ON da.department_id = r.department_id&lt;br&gt;
ORDER BY r.gap_days;&lt;br&gt;
With  the concepts combine: a window function (LAG) finds the previous discharge; a CTE chain keeps the steps readable and lets us filter on the window result; an aggregation inside dept_avg supplies a benchmark; and joins attach county and department names. The output is a ready-to-use quality-improvement list: one row per readmission with the gap in days and how costly the stay was relative to its department.&lt;/p&gt;

</description>
      <category>windowfunctions</category>
      <category>joins</category>
      <category>ctes</category>
      <category>subqueries</category>
    </item>
    <item>
      <title>Building the JCars logistics Sale Dashboard in Power BI</title>
      <dc:creator>Dorcas Chebet</dc:creator>
      <pubDate>Fri, 02 Oct 2026 11:19:34 +0000</pubDate>
      <link>https://dev.to/dorcas_chebet/building-the-jcars-logistics-sale-dashboard-in-power-bi-599g</link>
      <guid>https://dev.to/dorcas_chebet/building-the-jcars-logistics-sale-dashboard-in-power-bi-599g</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;JCars Logistics sells vehicles out of nine yards around Kenya — Nairobi, Mombasa, Kisumu,&lt;br&gt;
Eldoret, Kakamega and a few others. The business wanted answers to three plain&lt;br&gt;
questions: where do our sales actually come from, what do they earn us, and can we trust vehicles to reach customers on time?&lt;br&gt;
The data I was handed couldn’t answer any of that.It was 273 order rows across 55 columns, and when I checked, only 69 of those rows barely a quarter were clean enough to use.The rest had something wrong with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Investigating the data before touching it
&lt;/h2&gt;

&lt;p&gt;I didn’t open the file and start cleaning. First I wanted to know whether I could trust any of it.&lt;br&gt;
In Power Query I switched on Column quality, Column distribution and Column profile so I could see blanks and distinct values in every column at a glance. Then I started poking at the business logic. Does the recorded revenue match what the price, units and discount say&lt;br&gt;
it should be? Do delivery dates come after order dates, like they obviously should? Does each branch stay inside one region?&lt;br&gt;
Those checks surfaced five families of problems.&lt;br&gt;
Identity. 19 rows had no Order ID at all, and the ones that did used five different prefixes ORD, CAR, LCL, LCL-, LC. Without a key you can rely on, you can’t spot duplicates and you can’t build safe relationships.Prices, costs and fees were sitting in four currencies: KES, USD, EUR and ZAR.&lt;br&gt;
When I compared the converted figures against the originals, the implied rates were suspiciously constant (USD 129, EUR 151, ZAR 7.91), which told me someone had applied a fixed booking rate. Some rows had a currency I couldn’t identify, 10 showed negative revenue and 102 flat-out failed my reconciliation check. That check came straight from the&lt;br&gt;
data: Net Sales = Selling Price × Units × (1 − Discount), and the recorded revenue should&lt;br&gt;
equal either that or that plus the delivery fee. 102 rows matched neither.&lt;br&gt;
Time. Dates were stored as long text, like “Wednesday, March 26, 2025”. Once I converted &lt;br&gt;
them, the problems jumped out: 14 orders were apparently delivered before they were&lt;br&gt;
ordered (one of them 290 days before), 23 dates wouldn’t parse at all, and 56 were simply missing.&lt;br&gt;
Categories. The same thing was written several ways. Person and Individual. N.G.O and NGO, Saloon and Sedan, Wire, RTGS and Bank Transfer. Paid and Completed. Every spelling variant quietly splits a total that should have been one number.&lt;br&gt;
Logic. Delivered orders marked “Returned”. Cancelled orders carrying a “Completed”&lt;br&gt;
payment. Discounts as high as 50%. Vehicle years of 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning and preparation:
&lt;/h2&gt;

&lt;p&gt;what I did, and why For every fix I wrote down three things — what I did, why I did it and what I was assuming when I did it. The assumptions matter more than the clicks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Steps What I did Why, and what I assumed.
&lt;/h3&gt;

&lt;p&gt;Keys Standardized the Order IDs and added a surrogate key.I assumed the number identifies the order, so LC1000 and LCL1000 are the same order, just typed differently.Types Converted dates, money and flags to proper data types.Text dates break every time-intelligence function Currency Converted everything to KES at the rates the data implied.I assumed those were the business’s own booking rates.Revenue Rebuilt Revenue as&lt;br&gt;
net sales plus delivery fee.The recorded figure failed reconciliation on 102 rows. ORD1009, for instance, had no recorded revenue, but the rule gives 2,920,000 × 0.93 +140,000 = 2,855,600&lt;br&gt;
Dates Kept the impossible date rows but flagged them I assumed the order is real and the date is just wrong — deleting it would throw away a genuine sale Categories Merged the variants with a mapping table Cleaner slicers, and totals that actually add up Missing value State each choice and what it changed Unit economics Added Unit Selling&lt;br&gt;
Price and Unit Cost columns needed them to work out cost and margin per&lt;br&gt;
vehicle&lt;br&gt;
A few of those decisions came back to bite me, and I caught them during validation:&lt;br&gt;
Zeros aren’t blanks. A missing logistics cost that loads as 0 makes an order look more profitable than it really was. That’s not a rounding issue — it’s a wrong answer.&lt;br&gt;
A currency bug hiding in plain sight. One delivery fee read 426.67 sitting right next to a&lt;br&gt;
logistics cost of 74,000. That 426.67 is clearly a USD figure that never got converted&lt;br&gt;
(426.67 × 129 ≈ 55,000 KES)&lt;br&gt;
A duplicated customer ID. After one of the merges I ended up with both Customer ID and dim_customer.Customer ID in the fact table. I kept one and dropped the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modelling the data
&lt;/h2&gt;

&lt;p&gt;I built it as a star schema: one fact table (facts table, 276 rows) with five dimensions around&lt;br&gt;
it — dim_customer, dim_date, dim_location, dim_sale rep and dim_vehicle.&lt;br&gt;
Why a star schema rather than one big flat table?&lt;br&gt;
• Attributes like region, make and rep fall in exactly one place, so when I fix something, I fix it once.&lt;br&gt;
• The relationships are plain one-to-many, which keeps the DAX predictable and the reports quick.&lt;br&gt;
• dim_date is marked as the official date table, so time intelligence works properly.&lt;br&gt;
Delivery Date is a second date column, so it hangs off an inactive relationship and only wakes up inside measures that call USERELATIONSHIP.&lt;br&gt;
One limitation I made a point of writing down: the customer ID seems to track the source&lt;br&gt;
row number, which means a repeat buyer can get counted as several different customers.&lt;br&gt;
There are 181 distinct names across the 273 rows. So read any customer count with that&lt;br&gt;
caveat in mind — it’s an upper bound, not gospel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn8dl94uryhn5wwkgkk7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn8dl94uryhn5wwkgkk7.png" alt=" " width="799" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The DAX behind the numbers
&lt;/h3&gt;

&lt;p&gt;Each measure exists to answer one specific question. [Swap in your own measures.]&lt;br&gt;
Total Revenue =1.35bn&lt;br&gt;
CALCULATE&lt;br&gt;
Total Sales =SUM('facts table'[sales])&lt;br&gt;
Total= 1.82bn&lt;br&gt;
SUMX(&lt;br&gt;
 FILTER('facts table', 'facts table'[Delivery Status] &amp;lt;&amp;gt;&lt;br&gt;
"Cancelled"),&lt;br&gt;
 'facts table'[Units Sold] * 'facts table'[Unit Cost] + 'facts&lt;br&gt;
table'[Logistics Cost]&lt;br&gt;
)&lt;br&gt;
Unit Cost is per vehicle, so just summing the column would be nonsense. SUMX multiplies&lt;br&gt;
by units first, row by row.&lt;br&gt;
Gross Profit = [Total Revenue] - [Total Cost]&lt;br&gt;
total gross profit = 454.68m&lt;br&gt;
Gross Margin % = DIVIDE([Gross Profit], [Total Revenue])&lt;br&gt;
Gross Margin = 25%&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3oru0n055b77d3nnn1d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3oru0n055b77d3nnn1d.png" alt=" " width="336" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Designing the report
&lt;/h3&gt;

&lt;p&gt;I built the report around the three questions, one page each.&lt;br&gt;
Executive overview. KPI cards for Total sales, Gross Margin %,total cost and Total revenue recorded.&lt;br&gt;
Sales analysis. Revenue sliced by branch, sales rep, lead source, make and vehicle type,&lt;br&gt;
with slicers for year, region and make. This is the “where does the money come from?”&lt;br&gt;
Operations and delivery. Delivery days by branch, the spread of delivery times, and a table of the orders that ran late.&lt;br&gt;
A few design calls I stuck to:&lt;br&gt;
• A small palette, used consistently — the same colour always means the same thing.&lt;br&gt;
• Bars sorted by value, never alphabetically.&lt;br&gt;
• Drill-through from region down to branch down to the individual order.&lt;br&gt;
• Chart titles that say what you’re looking at, e.g. Revenue, total cost&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fww3kh8qkmuvjoxk0uiwi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fww3kh8qkmuvjoxk0uiwi.png" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Insights
&lt;/h2&gt;

&lt;p&gt;• Riftvalley region brought in Ksh.3,044,400 of revenue, while Western managed only Ksh.1,872,800&lt;br&gt;
• online produced the most orders, but walk in had the highest average&lt;br&gt;
order value.&lt;br&gt;
• SUV gave the best gross margin at 14.72%, against 4.22% for Toyota.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F26kptuwfi64w57tbl21j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F26kptuwfi64w57tbl21j.png" alt=" " width="800" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Recommendations
&lt;/h2&gt;

&lt;p&gt;Pin each recommendation to an insight above:&lt;br&gt;
• Move marketing spend toward the lead sources with the best margins, not just the&lt;br&gt;
most orders. Volume and profit aren’t the same thing.&lt;br&gt;
• Dig into the slow branches. Delays at [branch] are probably costing repeat business.&lt;br&gt;
• Revisit pricing and discounts on the lowest-margin makes.&lt;br&gt;
• Fix the data at the point of capture. Making currency and Order ID mandatory fields&lt;br&gt;
would have prevented most of what I found — remember that 75% of rows needed&lt;br&gt;
correcting.&lt;br&gt;
&lt;strong&gt;what i took away from it&lt;/strong&gt;&lt;br&gt;
Cleaning ate up most of the time, and that’s exactly where the real analytical decisions got&lt;br&gt;
made. The assumptions I logged turned out to matter as much as any chart.&lt;br&gt;
Checking my own work paid off. Validating the converted values and the filled-in blanks&lt;br&gt;
caught errors that would otherwise have shipped without a sound.&lt;br&gt;
I kept quality visible by holding on to an issues flag, so anyone can filter the report down to&lt;br&gt;
clean records and judge for themselves.&lt;br&gt;
And the big one: missing is not zero. Treat a blank cost or a blank revenue as 0 and you’ve silently poisoned every average built on top of it.&lt;br&gt;
If I did this again, I’d fix the capture process first. Clean inputs mean far less cleaning later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This project took the JCars Logistics dataset from a raw, inconsistent Excel file to an interactive Power BI report that management can use to make decisions. The process started with investigating and cleaning the data: fixing data types, handling missing values and duplicates, and standardizing text fields. I then shaped it into a star schema, with one facts table linked to the customer, date, location, sales rep and vehicle dimensions. That model made the DAX measures (revenue, gross profit, margin, logistics cost ratio, return rate and time comparisons) accurate and easy to reuse across every visual.&lt;/p&gt;

</description>
      <category>powerbi</category>
      <category>dataanalytics</category>
      <category>dax</category>
      <category>datavisualisation</category>
    </item>
    <item>
      <title>Assignment in power BI</title>
      <dc:creator>Dorcas Chebet</dc:creator>
      <pubDate>Tue, 22 Sep 2026 09:16:17 +0000</pubDate>
      <link>https://dev.to/dorcas_chebet/assignment-in-power-bi-19jl</link>
      <guid>https://dev.to/dorcas_chebet/assignment-in-power-bi-19jl</guid>
      <description>&lt;h2&gt;
  
  
  Data modelling in power Bi
&lt;/h2&gt;

&lt;p&gt;Data modelling is setting up tables, relationships, calculations and access for analysis scenarios.This helps so that data can be analyzed correctly and efficiently.&lt;br&gt;
for example, a sales department can have information about,&lt;/p&gt;

&lt;p&gt;-Order ID&lt;/p&gt;

&lt;p&gt;-Order date&lt;/p&gt;

&lt;p&gt;-Customer Name&lt;/p&gt;

&lt;p&gt;-City&lt;/p&gt;

&lt;p&gt;-Product&lt;/p&gt;

&lt;p&gt;-Category&lt;/p&gt;

&lt;p&gt;-Amount&lt;/p&gt;

&lt;p&gt;instead of putting everything into one large table ,the data can be organized into related tables.&lt;br&gt;
&lt;strong&gt;importance of a well designed model&lt;/strong&gt;&lt;br&gt;
1.Reporting and analytics&lt;br&gt;
it helps in data consistency by standardizing definitions and metrics across different systems so everyone uses the same number.it ensures filters and slicers automatically propagate across related visuals without missing or duplicating records.&lt;br&gt;
2.DAX calculations&lt;br&gt;
3.It prevents incorrect results when writting time-intelligence or cross table measures. &lt;br&gt;
4.it also simplifies code syntax.&lt;br&gt;
Performance&lt;br&gt;
5.A well-designed data model reduce unnecessary data duplication and improve power BI's ability to process queries efficiently.&lt;br&gt;
6.Scalability.It accommodate additional products, customers transactions and other data without the need of the entire structure to be redesigned.&lt;br&gt;
7.maintability.It makes it easier for another analyst to understand and modify the report.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data modelling approaches
&lt;/h3&gt;

&lt;p&gt;1.&lt;strong&gt;Flat table&lt;/strong&gt; &lt;br&gt;
it is a single ,wide table that contains all your data,e.g Order ID,Order date,Customer name,City,Product category and amount&lt;br&gt;
Example&lt;/p&gt;

&lt;p&gt;2.&lt;strong&gt;star schema&lt;/strong&gt; &lt;br&gt;
Definition: One central fact table surrounded by dimension tables, each joined directly to the fact table. It looks like a star.&lt;br&gt;
Example,&lt;br&gt;
                  dim_doctors&lt;br&gt;
                     |&lt;br&gt;
                     |&lt;br&gt;
dim_patients --- Visits_fact --- dim_department&lt;br&gt;
                     |&lt;br&gt;
                     |&lt;br&gt;
              dim_diagnosis&lt;br&gt;
                     |&lt;br&gt;
              dim_procedure&lt;br&gt;
                     |&lt;br&gt;
                  dim_wards&lt;br&gt;
&lt;em&gt;Advantages&lt;/em&gt;&lt;br&gt;
• Simple, intuitive and the layout Power BI is optimized for.&lt;br&gt;
• Fast: one hop from dimension to fact and efficient compression.&lt;br&gt;
• Easy DAX and easy filtering.&lt;br&gt;
• Easy for report authors to understand.&lt;br&gt;
&lt;em&gt;Advantages&lt;/em&gt;&lt;br&gt;
• Dimensions are denormalised, so some repetition remains (for example, category name repeated per product).&lt;br&gt;
• Requires upfront design and transformation effort.&lt;br&gt;
When appropriate: Almost all analytical models. It is Microsoft's recommended default.&lt;br&gt;
Performance/complexity: Best balance. Few relationships, short filter paths, low comp&lt;/p&gt;

&lt;p&gt;3.&lt;strong&gt;Snowflake schema approach&lt;/strong&gt;&lt;br&gt;
It is a star schema where dimensions are normalised into further related tables, so dimensions have their own sub-dimensions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuldsxv1f5y7frplt07ut.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuldsxv1f5y7frplt07ut.png" alt=" " width="626" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;advantages&lt;/strong&gt;&lt;br&gt;
• Less redundancy, so easier to maintain hierarchies (Category → Product).&lt;br&gt;
• Mirrors normalised source databases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disadvantages&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Easy to set up, with no need to handle any connections or relationships.&lt;br&gt;
Fine for quick, one-off exploration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disadvantages&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;-Heavy repetition of words many times, which makes the files bigger and the system slower.&lt;br&gt;
-Updating anomalies: when you rename a category, you have to change many rows.&lt;br&gt;
-It's difficult to create accurate time-based analysis without having a proper date table.&lt;br&gt;
Comparison&lt;br&gt;
1.&lt;strong&gt;Feature flat table star schema and snowflake schema&lt;/strong&gt;&lt;br&gt;
Tables can have one fact along with several dimensions, or they can include a fact along with dimensions and sub-dimensions.&lt;br&gt;
Redundancy High Moderate Low&lt;br&gt;
Performance in Power BI is poor when handling large data sets, but it's best when dealing with smaller data. It’s good, but slightly slower than the best.&lt;br&gt;
Model complexity Lowest Low Highest&lt;br&gt;
DAX simplicity Awkward Easy Moderate&lt;br&gt;
Recommended? Only small or ad hoc, yes (default), only when justified.&lt;br&gt;
2.&lt;em&gt;&lt;strong&gt;Fact table and Dimension Table&lt;/strong&gt;&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;A fact table keeps track of business events along with the numerical outcomes of those events. It typically contains:&lt;/p&gt;

&lt;p&gt;Foreign keys to dimensions (DateKey, ProductKey, CustomerKey)&lt;br&gt;
Numeric measures: Quantity, SalesAmount, Discount, Cost&lt;br&gt;
Sometimes degenerate dimensions such as an OrderNumber&lt;/p&gt;

&lt;p&gt;Fact tables are long because they have many rows, but they are not wide since they only have a few columns. Examples: FactSales, FactOrders, FactTransactions.&lt;br&gt;
Dimension tables&lt;/p&gt;

&lt;p&gt;A dimension table holds descriptive information that helps in filtering, grouping, and labeling the facts. It has a special key called the primary key and also includes text and category columns. Dimension tables are not very long, having fewer rows, but they have many columns that provide detailed descriptions.&lt;/p&gt;

&lt;p&gt;DimCustomer: CustomerKey, Name, Gender, Segment, Join Date&lt;br&gt;
DimProduct: ProductKey, Product Name, Category, Brand, Unit Price.&lt;br&gt;
DimDate: DateKey, Date, Month, Quarter, Year, Weekday&lt;br&gt;
DimLocation: LocationKey, City, County, Country&lt;br&gt;
Measures versus attributes&lt;br&gt;
Measures are numerical outcomes from business events that you combine by adding, averaging, or counting.&lt;br&gt;
Attributes are descriptive fields that you can use to break down or analyze your data, such as Category, City, or Month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grain&lt;/strong&gt;&lt;br&gt;
The grain refers to what is represented by one row in the fact table. Examples:&lt;/p&gt;

&lt;p&gt;"One row for each product in each order line" (FactSales).&lt;br&gt;
"One row per order" (FactOrders)&lt;br&gt;
One entry for each account every day, which is a daily balance summary.&lt;/p&gt;

&lt;p&gt;Define the grain first. Every measure and foreign key has to be accurate at that level of detail. Combining different types of grain values (such as order-level and line-level) in the same table leads to counting the same items twice.&lt;br&gt;
FactSales&lt;/p&gt;

&lt;p&gt;| Sales ID |Date key  |Customer key | Product key |Location key | Quantity |Sales amount|&lt;br&gt;
|---------|-----------|-------------|------------|--------------|&lt;br&gt;
|1        |20260105   | 11          |501         |3             |&lt;br&gt;&lt;br&gt;
|1        |65000&lt;br&gt;
|2        |20260105   |11           |502         |3             |&lt;br&gt;
|2        |3000&lt;br&gt;
|3        |20260106   |12           |501         |4             |&lt;br&gt;
|1        |65000      |&lt;/p&gt;

&lt;p&gt;Each foreign key refers to a dimension where the key is unique. Choosing "Mombasa" in the DimLocation or "Tech" in the DimProduct narrows down the FactSales data to only the rows that match those selections. This is the star schema presented in Section 1.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Relationships in Power BI
What is a relationship?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A relationship connects two tables by using a column in each, which helps Power BI understand how they are linked and allows it to pass filters from one table to the other. Without relationships, data from different tables can't be connected: if you select "Nairobi" in a slicer, it won't change the sales totals because Power BI won't know which rows of data belong to Nairobi.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keys, uniqueness and integrity
&lt;/h3&gt;

&lt;p&gt;Primary key (PK): A column that uniquely identifies every row in a table, like CustomerKey in DimCustomer.&lt;br&gt;
A foreign key is a column in one table that points to the primary key of another table, like the CustomerKey in the FactSales table.&lt;br&gt;
Unique values: The "one" side of a relationship should have distinct, non-empty values.&lt;br&gt;
Cardinality refers to the type of relationship between columns, such as one-to-many or other similar types.&lt;br&gt;
Referential integrity means that every value in a foreign key of the fact table must be found in the corresponding dimension table. If FactSales includes CustomerKey 99 but DimCustomer does not, those rows appear under a (Blank) category in visuals, making reports misleading.&lt;/p&gt;

&lt;p&gt;Why is CustomerID unique in DimCustomer but appears multiple times in FactSales: DimCustomer has one row for each customer, meaning each person is represented only once. FactSales records one entry for each time a customer makes a purchase, and a customer can make multiple purchases. CustomerID 11 is listed once in the DimCustomer table but appears many times in the FactSales table. That is exactly a one-to-many relationship.&lt;/p&gt;

&lt;p&gt;_One-to-many (1:*)&lt;/p&gt;

&lt;p&gt;How it works: A single row on the "one" side connects to multiple rows on the "many" side. Filters move from the single side to the multiple side.&lt;/p&gt;

&lt;p&gt;Example: DimProduct (1) → FactSales (*).&lt;/p&gt;

&lt;p&gt;DimProduct (1) ─────────────► (*) FactSales&lt;br&gt;
ProductKey PK ProductKey FK&lt;/p&gt;

&lt;p&gt;Use it: This is the standard, default relationship in star schemas. Use it whenever a dimension describes a fact.&lt;br&gt;
Avoid it: When the "one" column has duplicate entries. Power BI will then force a many-to-many.&lt;/p&gt;

&lt;p&gt;One-to-one (1:1)&lt;/p&gt;

&lt;p&gt;How it works: Each row in one table can match only one row in the other table, and both columns are unique. Filters propagate in both directions.&lt;/p&gt;

&lt;p&gt;Employees (EmployeeID) and EmployeeSensitiveDetails (EmployeeID), such as salary or national ID, are kept in a separate table for security.&lt;/p&gt;

&lt;p&gt;Employees (1) ◄────────────► (1) EmployeeDetails&lt;br&gt;
EmployeeID PK EmployeeID PK&lt;/p&gt;

&lt;p&gt;Use it: Occasionally, such as when dividing a table for security reasons or when two sources refer to the same entity.&lt;br&gt;
Usually, it's better to combine the two tables into one using Power Query, which makes the model easier to work with.&lt;/p&gt;

&lt;p&gt;Many-to-many (:)&lt;/p&gt;

&lt;p&gt;How it works: Neither column is unique, so there may be multiple rows that match on each side. Power BI doesn't require a unique key, but the results can vary based on how filters work and may be difficult to predict.&lt;/p&gt;

&lt;p&gt;Students and Courses, where a student enrolls in multiple courses and a course is attended by multiple students. Another example is budget data at the category level connected to sales data at the product level.&lt;/p&gt;

&lt;p&gt;Students (&lt;em&gt;) ◄────────► (&lt;/em&gt;) Enrollments?&lt;/p&gt;

&lt;p&gt;The recommended design is a bridge table:&lt;/p&gt;

&lt;p&gt;DimStudent has a one-to-many relationship with BridgeEnrollment, which in turn has a one-to-many relationship with DimCourse.&lt;/p&gt;

&lt;p&gt;Use it: Only when a bridge table isn't practical, or when you need to compare tables that have different levels of detail, like budget data grouped by month versus sales data grouped by day.&lt;br&gt;
Avoid it: As a shortcut to skip cleaning your data. It causes ambiguity and unexpected totals.&lt;/p&gt;

&lt;p&gt;Active and inactive relationships&lt;/p&gt;

&lt;p&gt;At any given time, only one active connection can exist between two tables, and this is shown as a solid line in the Model view. Additional ones are inactive (dotted).&lt;/p&gt;

&lt;p&gt;FactSales has OrderDate and ShipDate, both connecting to DimDate[Date]. OrderDate is the active relationship. To calculate sales by ship date, turn on the other one in DAX:&lt;/p&gt;

&lt;p&gt;DAX&lt;br&gt;
Sales by Ship Date =&lt;br&gt;
CALCULATE (&lt;br&gt;
SUM ( FactSales[SalesAmount] ),&lt;br&gt;
USERELATIONSHIP ( FactSales[ShipDate], DimDate[Date] )&lt;br&gt;
)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Filter Direction
How filters propagate&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Filters travel along relationships. In a standard star schema, when you apply a filter to a dimension, that filter is passed down to the fact table.&lt;/p&gt;

&lt;p&gt;Single-direction filtering&lt;/p&gt;

&lt;p&gt;Filters only move from the "one" side to the "many" side.&lt;br&gt;
A user chooses the category "Tech" in the DimProduct slicer.&lt;/p&gt;

&lt;p&gt;DimProduct is filtered to FactSales, and the Total Sales shows only Tech sales.&lt;br&gt;
The slicer filters DimProduct to Tech products.&lt;br&gt;
That filters FactSales to include only the rows where the ProductKey matches the specified products.&lt;br&gt;
SUM(FactSales[SalesAmount]) returns Tech sales only.&lt;/p&gt;

&lt;p&gt;This is the default and preferred behaviour.&lt;/p&gt;

&lt;p&gt;Both (bidirectional) filtering&lt;/p&gt;

&lt;p&gt;Filters work both ways: a filter applied to the fact table also affects the dimension.&lt;/p&gt;

&lt;p&gt;DimProduct ◄──filter──► FactSales&lt;/p&gt;

&lt;p&gt;Legitimate use: For instance, a slicer on DimProduct that displays only products with actual sales, or many-to-many relationships handled through a bridge table.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How to use it carefully&lt;/em&gt;&lt;br&gt;
Ambiguous filter paths: When multiple dimensions are linked to a single fact table, bidirectional filters can lead to different paths connecting two tables. Power BI might not allow the relationship to be activated or could cause unexpected outcomes.&lt;br&gt;
Unexpected results happen when filters affect the entire model, meaning a slicer applied to one dimension can also filter another.&lt;br&gt;
Performance cost: Using filters can make calculations slower because they require more processing work.&lt;br&gt;
The model becomes more difficult to understand and fix.&lt;/p&gt;

&lt;p&gt;Performance/complexity: Lowest modelling complexity but poor scalability. Memory usage increases rapidly when text columns are repeated.&lt;/p&gt;

&lt;p&gt;Star schema&lt;/p&gt;

&lt;p&gt;Definition: There is one main fact table with dimension tables around it, and each dimension table is connected directly to the fact table. It looks like a star.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqisk07gd3uue5fbg0yt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqisk07gd3uue5fbg0yt.png" alt=" " width="646" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Best practice is to keep everything set to one direction by default, and only turn on two-way filtering when it's actually needed. Often the same outcome can be reached more safely in DAX by using CROSSFILTER() within a single measure.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Joins in Power Query
A join combines rows from two tables by matching them on a common column. In Power Query, you perform this action by going to the Home tab and selecting Merge Queries, or choosing Merge Queries as New. You pick two tables, choose the matching column in each one, select the type of join you want, and then expand the joined table to include its columns.
Example data&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Customers table&lt;br&gt;
|Customer ID| Name    |City&lt;br&gt;
|&lt;br&gt;
|-----------|---------|-----&lt;br&gt;
-|C1        | Brian   |Nairobi&lt;br&gt;
|&lt;br&gt;
|C2         | Amos    | Mombasa&lt;/p&gt;

&lt;p&gt;|C3         |Dorcas   |Kisumu&lt;/p&gt;

&lt;p&gt;|C4         |Cynthia  |Nakuru&lt;/p&gt;

&lt;p&gt;Orders&lt;/p&gt;

&lt;p&gt;OrderID CustomerID Amount&lt;br&gt;
O101 C1 5,000&lt;br&gt;
O102 C1 2,500&lt;br&gt;
O103 C3 8,000&lt;br&gt;
O104 C5 1,200&lt;/p&gt;

&lt;p&gt;Note: Brian (C2) and David (C4) have no orders, and order O104 belongs to C5, who is not in Customers. Merges always start by using Customers as the first table on the left and Orders as the second table on the right, pairing them based on the CustomerID.&lt;/p&gt;

&lt;p&gt;Left Outer Join&lt;/p&gt;

&lt;p&gt;How it works: It keeps all the rows from the left table and only includes the matching rows from the right table.&lt;br&gt;
Records kept: All customers and corresponding order information are retained. Unmatched customers get nulls.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp3fqmc7zobg93qhp50q7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp3fqmc7zobg93qhp50q7.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Right Outer Join&lt;/p&gt;

&lt;p&gt;How it works: It keeps all the rows from the right table and only the matching rows from the left.&lt;br&gt;
Records kept: Every order along with the corresponding customer information.&lt;/p&gt;

&lt;p&gt;CustomerID Name City OrderID Amount&lt;br&gt;
C1 Amina Nairobi O101 5,000&lt;br&gt;
C1 Amina Nairobi O102 2,500&lt;br&gt;
C3 Cynthia Kisumu O103 8,000&lt;br&gt;
null null null O104 1,200&lt;/p&gt;

&lt;p&gt;Use case: Display all orders and mark those that don't have a corresponding customer record.&lt;/p&gt;

&lt;p&gt;Full Outer Join&lt;/p&gt;

&lt;p&gt;How it works: It keeps all the rows from both tables and matches them where possible.&lt;br&gt;
Records are kept: all information is stored; missing data is represented by nulls where there is no matching information.&lt;/p&gt;

&lt;p&gt;CustomerID Name City OrderID Amount&lt;br&gt;
C1 Amina Nairobi O101 5,000&lt;br&gt;
C1 Amina Nairobi O102 2,500&lt;br&gt;
C2 Brian Mombasa null null&lt;br&gt;
C3 Cynthia Kisumu O103 8,000&lt;br&gt;
C4 David Nakuru null null&lt;br&gt;
null null null O104 1,200&lt;/p&gt;

&lt;p&gt;Use case: Combining two systems to identify all the matches and differences in a single outcome.&lt;br&gt;
Inner Join&lt;/p&gt;

&lt;p&gt;How it works: It keeps only the rows that are the same in both tables.&lt;br&gt;
Records kept: Only customers who have placed orders, and orders that are linked to customers.&lt;/p&gt;

&lt;p&gt;CustomerID Name City OrderID Amount&lt;br&gt;
C1 Amina Nairobi O101 5,000&lt;br&gt;
C1 Amina Nairobi O102 2,500&lt;br&gt;
C3 Cynthia Kisumu O103 8,000&lt;/p&gt;

&lt;p&gt;Use case: "Analyze customers who are currently active and have made a purchase."&lt;/p&gt;

&lt;p&gt;Left Anti Join&lt;/p&gt;

&lt;p&gt;How it works: It keeps the rows from the left table that do not have a matching row in the right table.&lt;br&gt;
Records retained: Customers with no orders.&lt;/p&gt;

&lt;p&gt;CustomerID Name City&lt;br&gt;
C2 Brian Mombasa&lt;br&gt;
C4 David Nakuru&lt;/p&gt;

&lt;p&gt;Use case: Identifying customers who have not placed any orders yet in order to run a marketing campaign.&lt;/p&gt;

&lt;p&gt;Right Anti Join&lt;/p&gt;

&lt;p&gt;How it works: It keeps the rows from the right table that do not have a matching row in the left table.&lt;br&gt;
Records retained: Orders with no matching customer.&lt;/p&gt;

&lt;p&gt;OrderID CustomerID Amount&lt;br&gt;
O104 C5 1,200&lt;/p&gt;

&lt;p&gt;Use case: Identifying records that are no longer linked to any other data, which is a data quality check to ensure that all references between data sets are correct and complete.&lt;/p&gt;

&lt;p&gt;Summary&lt;br&gt;
Join type Keeps&lt;br&gt;
Left Outer All left + matching right&lt;br&gt;
Right Outer All right + matching left&lt;br&gt;
Full Outer Everything from both&lt;br&gt;
Inner Matching rows only&lt;br&gt;
Left Anti Left rows with no match&lt;br&gt;
Right Anti Right rows with no match&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Power Query Joins vs Power BI Relationships&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both link tables together, but they function in different steps and serve different reasons.&lt;/p&gt;

&lt;p&gt;Aspect Power Query join (Merge) Power BI relationship&lt;br&gt;
When it happens, it can occur during data loading or refreshing (ETL stage) or when a report or query is run.&lt;br&gt;
What it does is physically combines tables into a new table or adds columns. It keeps tables separate and links them logically.&lt;br&gt;
Result: One wider table (rows may multiply or shrink) Filters propagate between tables; no data merged.&lt;br&gt;
Join types include six kinds such as left, inner, anti, and others. Cardinality includes ratios like 1 to many, 1 to 1, and so on. Filter direction is also considered.&lt;br&gt;
Impact on model size can lead to larger size and extra copies. It is efficient because each entity is stored only once.&lt;br&gt;
Aggregation values are set at the row level. Dynamic: measures update based on the filter context.&lt;br&gt;
Best for data preparation: adding more information, fixing errors, making data easier to use, handling complex structures, and finding records that don't match. Analytical modeling: breaking down data into different categories to analyze it effectively.&lt;/p&gt;

&lt;p&gt;Rule of thumb:&lt;/p&gt;

&lt;p&gt;Use Power Query joins to clean and structure your data: simplify a snowflaked dimension, add more information to a table using a lookup column, or find rows that don’t have matches with an anti join.&lt;br&gt;
Use relationships to analyze data by linking a fact table to dimensions, allowing measures to react to slicers and filters.&lt;/p&gt;

&lt;p&gt;Merge the DimProduct and DimCategory tables in Power Query to create a single DimProduct table, which flattens the snowflake schema. Link the DimProduct table to the FactSales table using a one-to-many relationship. Combining FactSales with all dimensions into a single flat table is possible, but it reduces the performance, flexibility, and ease of maintenance that a star schema provides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A good Power BI model keeps facts like events and measurements separate from dimensions that provide descriptive details. It connects them with the right one-to-many relationships and uses filters that only go in one direction by default. The star schema is the preferred approach since it is efficient, straightforward, and works well with DAX. Power Query joins help organize the data and also show any issues with data quality by using anti joins, while relationships allow the final model to answer business questions in a flexible way.&lt;/p&gt;

</description>
      <category>modelling</category>
      <category>schemas</category>
      <category>joins</category>
      <category>relationships</category>
    </item>
    <item>
      <title>Building an interactive Excel Dashboard for E-commerce Product Analysis: A Case Study of Jumia Products</title>
      <dc:creator>Dorcas Chebet</dc:creator>
      <pubDate>Wed, 09 Sep 2026 11:06:44 +0000</pubDate>
      <link>https://dev.to/dorcas_chebet/building-an-interactive-excel-dashboard-for-e-commerce-product-analysis-a-case-study-of-jumia-4h43</link>
      <guid>https://dev.to/dorcas_chebet/building-an-interactive-excel-dashboard-for-e-commerce-product-analysis-a-case-study-of-jumia-4h43</guid>
      <description>&lt;h1&gt;
  
  
  Introduction
&lt;/h1&gt;

&lt;p&gt;The case study is about jumia products which has generated large amount of data that include a certain number of products, prices, discounts, ratings and customer review .It contains information on 113 products .It also include their current prices, old prices, discount percentage s,number of customer review and ratings .Informations help the business to know the product performance and identify areas that requires attention.&lt;br&gt;
The project involves transforming raw data into a structured dataset ,performing explolatory analysis and presenting the results through an interactive dashboard.&lt;/p&gt;

&lt;h1&gt;
  
  
  Project Objective.
&lt;/h1&gt;

&lt;p&gt;The main project objective of this project is to analyze data from jumia.&lt;br&gt;
the specific objectives are to analyze products by comparing current prices with the old prices.&lt;/p&gt;

&lt;p&gt;-To identify the discount offered in different products&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;To identify products with high,medium and low discounts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To determine whether highly rated products receive more customer reviews&lt;/p&gt;

&lt;p&gt;-To calculate descriptive statistics such as average,current price,average old price,average discount,total discount,total reviews and number of products.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which project generated more reviews&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To use excel formulas  to organize, categorise and interpret the Jumia product data.&lt;/p&gt;

&lt;h1&gt;
  
  
  Dataset and Business Queastions
&lt;/h1&gt;

&lt;p&gt;The questions guiding the analysis were:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does a higher discount correspond to more reviews?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;2.Do highly rated products receive more reviews?&lt;/p&gt;

&lt;p&gt;3.Is there a relationship between current price and ratings?&lt;/p&gt;

&lt;p&gt;4.Which products have the highest rating?&lt;/p&gt;

&lt;p&gt;5.Which products have the lowest discounts?&lt;/p&gt;

&lt;p&gt;6.Which products have the highest number of  reviews?&lt;/p&gt;

&lt;p&gt;7.Are there products with high discount but relatively weak ratings or engagements?&lt;br&gt;
The questions helped determine which calculations,pivot tables and visualization were needed.&lt;/p&gt;

&lt;h1&gt;
  
  
  Initial Data-Quality Audit
&lt;/h1&gt;

&lt;p&gt;Before cleaning i identified the problems before making the changes or cleaning the dataset inorder to understand whether the data was complete ,consistent ,accurate and suitable for analysis.&lt;br&gt;
For the jumia project the audit included:&lt;/p&gt;

&lt;p&gt;1.Identifying if there was duplicate&lt;br&gt;
2.Identify if there was missing values in important field such as price ,rating ,discount or reviews.&lt;br&gt;
3.incorrect values :values that do not add up, such as current price being the same as the old price while a discount is still recorded.&lt;br&gt;
4.Data type errors :to ensure prices are stored as numbers ,discounts as percentages/numbers, ratings as numbers and products names as text.&lt;/p&gt;

&lt;h1&gt;
  
  
  Excel techniques ,formulas and analysis performed.
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Calculating the Discount amount
&lt;/h2&gt;

&lt;p&gt;I calculated the discount amount by subtracting the current price from the old price.&lt;br&gt;
formula:&lt;br&gt;
=old price-current price&lt;br&gt;
use of IF statements to classify products into different categories based on their ratings, discounts and prices.&lt;br&gt;
I classified into categories such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Excellent&lt;/li&gt;
&lt;li&gt;Average&lt;/li&gt;
&lt;li&gt;Poor&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Missing&lt;br&gt;
Discounts were categorized as:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;High discount&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Medium discount&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Low discount&lt;/p&gt;
&lt;h2&gt;
  
  
  Analysis performed
&lt;/h2&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Rating analysis
I analyzed and group product ratings into categories to determine which products have excellent, average, poor or missing ratings to help evaluate overall customer satisfaction.&lt;/li&gt;
&lt;li&gt;Discount analysis
I analyzed and categorized into high, medium and low discounts .I also compared the old prices with the current price to determine the amount customers could save.&lt;/li&gt;
&lt;li&gt;Trend analysis
The project was used to examine relationships between:
-Discount percentage and number of customer review
-Product rating and number of customer reviews
-Product price and product rating.
The analysis indicates that a higher discount percentage did not result in more customer reviews and highly rated products did not necessarily receive more reviews.
#Dashboard creation process
The final stage of the project was to transform the analysis into an interactive dashboard .Top most of the Dashboard was the heading written in bold.
The key performance indicators were&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Total products&lt;/li&gt;
&lt;li&gt;Average Current price KSh 1,097&lt;/li&gt;
&lt;li&gt;Average Discount 48%&lt;/li&gt;
&lt;li&gt;Average rating  5
-Total reviews  723
Beneath this KPIs the slicers used were:&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;price category-it categorized data into High price ,Low price and Medium price.
Discount category- it categorized data into High discount ,Low discount and medium discount.
Rating Category-Categorized data into average, excellent ,poor and missing for missing values.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;key insights and business recommendation.&lt;/p&gt;

&lt;p&gt;1.Higher discounts do not necessarily lead to higher customer engagement .Products with higher discount percentages doo not consistently receive more customer review.&lt;br&gt;
2.Customer reviews vary across products&lt;br&gt;
3.Discounting should not be the only strategy&lt;br&gt;
4.Products performance should consider both ratings and reviews.&lt;br&gt;
5.Highly rated products do not necessarily receive more reviews.it does not guarantee strong customer engagement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recommendations&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The sellers should focus on customer satisfaction by improving quality 
and customer service to maintain good ratings.&lt;/li&gt;
&lt;li&gt;the sellers should use data to make pricing decisions&lt;/li&gt;
&lt;li&gt;Review products with low engagement.&lt;/li&gt;
&lt;li&gt;Use target discounts .Strategically on products where they are likely to improve sales or engagement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Screenshot of original data, cleaned data ,pivot tables and dashboards&lt;/strong&gt; &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Original Data&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2c7zvvzftql0m0uiibf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2c7zvvzftql0m0uiibf.png" alt=" " width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;2.Cleaned data&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqp06y8tdghqufyy05hal.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqp06y8tdghqufyy05hal.png" alt=" " width="799" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;3.Pivot tables&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foerix7lj9dlific39vqx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foerix7lj9dlific39vqx.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwb5atblk7dv08ej4hug3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwb5atblk7dv08ej4hug3.png" alt=" " width="580" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd6abg2247u411i4i4i3f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd6abg2247u411i4i4i3f.png" alt=" " width="752" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;3.Dashboard&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F92cnutp37z705rzrvail.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F92cnutp37z705rzrvail.png" alt=" " width="800" height="227"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>analysis</category>
      <category>data</category>
      <category>software</category>
    </item>
    <item>
      <title>ASSIGNMENT WEEK 2</title>
      <dc:creator>Dorcas Chebet</dc:creator>
      <pubDate>Sun, 30 Aug 2026 07:49:35 +0000</pubDate>
      <link>https://dev.to/dorcas_chebet/assignment-week-2-455j</link>
      <guid>https://dev.to/dorcas_chebet/assignment-week-2-455j</guid>
      <description>&lt;h1&gt;
  
  
  Getting Started with Excel For Data Analysis
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Excel is one of the tools commonly used for working data .It is used to enter, organize ,clean, calculate and analyze information using spreadsheets .learning excel gives good foundation on how data is handled before other advanced tools such as python ,SQL and Power BI are used.&lt;br&gt;
In data analysis ,raw data can have errors, duplicates, missing values or unorganized information.&lt;/p&gt;

&lt;h3&gt;
  
  
  Excel Basics
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Dataset- It is a collection of related information organized into rows and columns .Example,&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Employee ID&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Department&lt;/th&gt;
&lt;th&gt;Age&lt;/th&gt;
&lt;th&gt;Salary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1102&lt;/td&gt;
&lt;td&gt;Marion&lt;/td&gt;
&lt;td&gt;Finance&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;45000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1103&lt;/td&gt;
&lt;td&gt;Peter&lt;/td&gt;
&lt;td&gt;IT&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;70000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1104&lt;/td&gt;
&lt;td&gt;June&lt;/td&gt;
&lt;td&gt;HR&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;60000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1105&lt;/td&gt;
&lt;td&gt;Irene&lt;/td&gt;
&lt;td&gt;Finance&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;80000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1106&lt;/td&gt;
&lt;td&gt;Dennis&lt;/td&gt;
&lt;td&gt;IT&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;65000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Explanation&lt;br&gt;
_column-is a verticle line of data that runs straight from top to bottom&lt;br&gt;
_row-it is a horizontal line of data that runs from left to right.&lt;br&gt;
_cell-it is an intersection of a row and column&lt;br&gt;
_Header-The name of a column.&lt;br&gt;
entering data into Excel&lt;br&gt;
for the case of our dataset, it is entered as,&lt;br&gt;
A1:Employees ID&lt;br&gt;
B2:Name&lt;br&gt;
C1:Department&lt;br&gt;
D1:Age&lt;br&gt;
E1:Salary&lt;br&gt;
It is important to organize data rather than putting two different pieces of information in the same column. For example:&lt;br&gt;
Employee ID &lt;br&gt;
1102,Marion,finance&lt;br&gt;
instead;&lt;br&gt;
Employee ID     Name     Department&lt;br&gt;
1102            Marion   Finance&lt;br&gt;
Hence the data will be easier to analyze.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formatting in Excel
&lt;/h2&gt;

&lt;p&gt;Formatting in excel involves,&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Bold headings&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Number of formatting&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Alignment&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Adjusting column width&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dates&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Borders&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Percentages&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Currency&lt;br&gt;
formatting makes easy to interpret data but it doesn't clean the data.&lt;/p&gt;
&lt;h4&gt;
  
  
  Formulas applied in Excel.
&lt;/h4&gt;

&lt;p&gt;A formula directs on how to perform a calculation .For example when you enter&lt;br&gt;
=60+85&lt;br&gt;
Excel calculates as 145&lt;br&gt;
To calculate average salary the formula is&lt;br&gt;
=AVERAGE(range criteria)&lt;br&gt;
=AVERAGE(D2:D5)&lt;br&gt;
To calculate the highest salary&lt;br&gt;
=MAX(D2:D5)&lt;br&gt;
To calculate the lowest salary&lt;br&gt;
=MIN(D1:D5)&lt;br&gt;
To count the number of employees&lt;br&gt;
=COUNTA(A1:A5)&lt;/p&gt;
&lt;h2&gt;
  
  
  Sorting
&lt;/h2&gt;

&lt;p&gt;sorting is organizing data in a particular order.&lt;br&gt;
Example :Employees can be sort according to their departments,&lt;br&gt;
Names      Departments&lt;br&gt;
Marion     Finance&lt;br&gt;
Peter      IT&lt;br&gt;
June       HR&lt;br&gt;
Irene      Finance&lt;br&gt;
Dennis     IT&lt;br&gt;
This sorted data enables the analyst to quickly identify an employee which department the employees comes from.&lt;/p&gt;
&lt;h2&gt;
  
  
  Filtering of data
&lt;/h2&gt;

&lt;p&gt;Filtering hides data that does not meet a particular condition.&lt;br&gt;
For example, since our dataset contains employees from different departments, we can filter the column to only show employees in IT departments ,filtering does not delete the other records .It only hides since it does not match the selected condition.&lt;/p&gt;
&lt;h2&gt;
  
  
  Data cleaning
&lt;/h2&gt;

&lt;p&gt;It is the process of finding and correcting errors in a dataset.&lt;br&gt;
Common errors in a dataset involves,&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Missing values&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Duplicate values&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Inconsistent information&lt;br&gt;
In data cleaning ,excel functions are:&lt;br&gt;
UPPER&lt;br&gt;
=UPPER(A2) It helps changes the text to uppercase&lt;br&gt;
LOWER&lt;br&gt;
=lOWER it help changes the text to lowercase&lt;br&gt;
TRIM&lt;br&gt;
=TRIM(A2) It help removes unnecessary spaces from text.&lt;/p&gt;
&lt;h2&gt;
  
  
  Preparation of data for analysis.
&lt;/h2&gt;

&lt;p&gt;It is the process of cleaning, organizing raw data to be accurately ready for analysist avoid misleading results.&lt;br&gt;
The analyst should ensure that the dataset is well structured, complete ,consistent ,accurate and identify missing values.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  GitHub Repository
&lt;/h2&gt;

&lt;p&gt;You can find my Excel data analysis project on GitHub:&lt;/p&gt;

&lt;p&gt;[&lt;a href="https://github.com/chebetdorcas3-bot/my-second-project" rel="noopener noreferrer"&gt;view my GitHub Repository&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>ASSIGNMENT WEEK 1</title>
      <dc:creator>Dorcas Chebet</dc:creator>
      <pubDate>Sun, 23 Aug 2026 11:37:33 +0000</pubDate>
      <link>https://dev.to/dorcas_chebet/assignment-week-1-334a</link>
      <guid>https://dev.to/dorcas_chebet/assignment-week-1-334a</guid>
      <description>&lt;h1&gt;
  
  
  Understanding the Git workflow.
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Git is a system that is used incase one needs to track their projects or check if there was changes or need to get back to the initial project.&lt;br&gt;
Git is important since it simplify the work of re-writting the project over again by keeping track on the initial project through tracking the changes in the file or checking on what has changed or under certain changes.&lt;br&gt;
A complete work flow involves,&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;working directory&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;staging area&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;commit&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;push&lt;/p&gt;
&lt;h2&gt;
  
  
  working directory.
&lt;/h2&gt;

&lt;p&gt;It is a folder in your laptop or computer where you work on your project .Working directory is where you:&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;create files&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;edit files &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;delete files&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;add data and make changes to your project.&lt;br&gt;
The commands that are used in the working directory include,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;pwd-it shows your current location&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ls_it is used for listing files and a folder. &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;cd folder-it moves into a folder.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;cd..-it is used if one needs to move back to the previous folder.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;mkdir-it creates a new file folder.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;touch file-wit creates a new empty folder.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;git status-command used in tracking a folder.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;git add-moves changes to staging area.&lt;/p&gt;
&lt;h2&gt;
  
  
  staging area.
&lt;/h2&gt;

&lt;p&gt;It is where you put the changes you have chosen to save in your next commit in git.&lt;/p&gt;
&lt;h3&gt;
  
  
  The commands that are used in staging area:
&lt;/h3&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;git add- stage all the changes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;git status- check what is staged&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;git diff--staged -sees what is exactly staged&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;git restore--staged-it removes a file from staging&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;git commit -m -it ensure you run git add before committing&lt;/p&gt;
&lt;h2&gt;
  
  
  Commit
&lt;/h2&gt;

&lt;p&gt;It is a back up of the changes that have been placed in the staging area. &lt;br&gt;
Good commit messages can tell what happened.&lt;/p&gt;
&lt;h2&gt;
  
  
  Push
&lt;/h2&gt;

&lt;p&gt;Git push is a command that takes the commits made in a computer and send them to a repository such as Github.It is the final stage in working directory that sends the project to remote or local repository.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>beginners</category>
      <category>git</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>assignment</title>
      <dc:creator>Dorcas Chebet</dc:creator>
      <pubDate>Thu, 20 Aug 2026 10:15:21 +0000</pubDate>
      <link>https://dev.to/dorcas_chebet/assignment-4jjo</link>
      <guid>https://dev.to/dorcas_chebet/assignment-4jjo</guid>
      <description>&lt;p&gt;assinment week 1 article submission&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
