Mert Özen Further With Every Line
Project Management Jira and Project Tools — #03

JQL, Filters and Dashboards: Asking the Right Question Without Misusing Metrics

Mert Özen Nov 15, 2026 13 min 6 views
JQL, Filters and Dashboards: Asking the Right Question Without Misusing Metrics

JQL works with clauses made of a field, an operator and a value or function; currentUser(), openSprints(), startOfWeek() and membersOf() each support only certain operators. The post covers the Bookstore team's queries, keeping filters in the repo and checking them with a .NET tool, sharing filters and dashboards, which question each Jira report answers and the trap of turning a dashboard into a personal performance board.

Version note: The C# code in this post was built and run with .NET 10 (SDK 10.0.402). The JQL and Jira behaviour are based on the Jira Cloud documentation as of 2026-11 (Atlassian Support, checked on 15 November 2026).

Guess First

The team behind Bookstore, the online bookstore used as the shared example across this site's series, is in the middle of its second sprint. Three people save three things on the same day. Emre writes project = BOOK AND sprint = openSprints() AND status = Done to see the work finished in the sprint. Ayşe appends updated < -2d to her query to find PRs waiting in review. And Selin, the Product Owner, creates a dashboard called "Team Performance" for management: the number of items each developer resolved this sprint, side by side.

Guess: which of the three makes Jira return an error, which departs from the form the documentation asks for, and which is technically flawless and still harms the team? The answers are in the post; the third has nothing to do with JQL and everything to do with the question being asked.

The Structure of JQL: Field, Operator, Value

JQL looks like SQL but is not a database query language; Atlassian's documentation stresses this. The smallest unit is a clause: a field, an operator and one or more values or functions. Clauses are combined with AND, OR and NOT, and the result is sorted with ORDER BY. Without parentheses the expression is evaluated left to right; if you want precedence, you add parentheses.

project = BOOK AND type = Bug AND statusCategory != Done ORDER BY priority DESC, created ASC

(status = "In Review" AND project = BOOK) OR assignee = currentUser()

assignee != currentUser() OR assignee is EMPTY

The third line shows a rule that is often missed: JQL does not include empty fields in != and NOT IN results. If "work not assigned to me" should also show unassigned work, you have to add the is EMPTY clause yourself. The second rule is about dates: date fields such as created, updated and resolved accept a relative value like "-5d" (w weeks, d days, h hours, m minutes). The documentation asks for this value in quotes, because a number without quotes is read as milliseconds since 1970. Ayşe's updated < -2d is therefore not the form the documentation recommends.

Field names are in transition. JQL has a space field and project works as before; for the work type you can use type and its alias workType. The JQL fields page says some entries using the new terms may not work yet and that you should try the old term in that case. The examples in this post use project, which works either way.

This section is based on the official documentation.

Functions: currentUser, openSprints, startOfWeek, membersOf

JQL functions make a query last: instead of a sprint number or a date you can say "the sprint that is open now" or "the start of this week". Here are the four functions the Bookstore team uses most, with the rules the documentation gives:

  • currentUser(): the user running the query. It only works with = and != and with user fields such as assignee, reporter and watcher; it cannot be used for users who are not logged in.
  • openSprints() and closedSprints(): sprints that have started but not been completed, or that have been completed. They only work with IN and NOT IN; Emre's sprint = openSprints() uses an operator the documentation lists as unsupported.
  • startOfWeek(), startOfDay() and friends: the start of the period, with an optional increment such as "-1" or "+1d". The first day of the week is Sunday unless the ISO8601 option for the date picker is turned on in the Look and feel settings; for Monday you write startOfWeek("+1d").
  • membersOf(): the members of a group or a team. It only works with IN, NOT IN, WAS IN and WAS NOT IN; if it returns more than 10,000 users the search fails with an error, and teams are matched by their team ID with the id: prefix rather than by name.

Functions can also be combined with history operators. The CHANGED operator searches for changes to fields such as status, assignee, priority and resolution with FROM, TO, BY, BEFORE and AFTER, and the documentation's example uses it with functions, as in BEFORE endOfWeek() AFTER startOfWeek().

This section is based on the official documentation.

The Bookstore Team's Queries

The team's board and dashboard are fed by the queries below. Each answers a single question; a filter whose question you cannot write down is probably unnecessary.

assignee = currentUser() AND statusCategory = "In Progress" AND updated <= "-5d" ORDER BY updated ASC

project = BOOK AND status = "In Progress" AND status CHANGED TO "In Progress" BEFORE startOfDay("-5d") ORDER BY updated ASC

project = BOOK AND status = "In Review" AND assignee in membersOf("bookstore-developers") ORDER BY updated ASC

project = BOOK AND type = Bug AND resolved >= startOfWeek("+1d") ORDER BY resolved DESC

project = BOOK AND sprint in openSprints() AND statusCategory = Done ORDER BY key ASC

The first query asks "which work assigned to me looks in progress but has not been updated for five days?"; every developer checks it before the Daily Scrum. The second is slightly different: items whose status moved to In Progress more than five days ago and that are still there. It is the closest JQL gets to the work item age from the fourth post of the Kanban series, but not an exact match: if an item entered In Progress twice (say, it came back through Request Changes), the older move also satisfies the clause. The third is the review queue, the fourth lists bugs resolved this week (from Monday) and the fifth shows work finished in the sprint.

Two questions were in the plan but have no direct JQL answer. There is no JQL clause for "items added after the sprint started"; the Sprint Report marks these items with an asterisk, and that is the right place to look. "Bugs not linked to an epic" has become harder too: old "Epic Link" is EMPTY filters keep working, but Epic Link was retired in February 2024 and new filters have to use Parent. The documentation lists =, !=, IN and NOT IN as Parent's operators; IS EMPTY is not on that list, and because of the rule above parent != BOOK-120 does not return bugs with no parent. Before building this query, check the JQL editor's syntax help and autocomplete on your own site; a team rule such as "every bug gets a parent during triage" is often more robust than a query.

This section is based on the official documentation.

Keeping Filters in the Repo and Checking Them

The team decided to keep the JQL of the filters that feed its boards and dashboards in a plain text file in the Bookstore repo. The point is not to manage Jira from the repo; it is that a change to a filter shows up in a PR and simple mistakes are caught before review. The format is simple: one name and one JQL per line.

# Bookstore team filters (space key BOOK)
my-stale-work: assignee = currentUser() AND statusCategory = "In Progress" AND updated <= "-5d" ORDER BY updated ASC
bugs-without-epic: project = BOOK AND type = Bug AND "Epic Link" is EMPTY
done-this-sprint: project = BOOK AND sprint = openSprints() AND status = Done
review-queue: project = BOOK AND assignee in membersOf("bookstore-developers") AND status = "In Review" AND updated < -2d ORDER BY updated ASC

The check is done by a small .NET 10 console app, Bookstore.JiraTools. It is not a JQL parser; it is a lint tool that looks for the rules we took from the documentation above with regular expressions. Each rule rests on a line of the documentation:

using System.Text.RegularExpressions;

namespace Bookstore.JiraTools;

public enum Severity { Info, Warn }

public sealed record LintRule(string Id, Severity Severity, Regex Pattern, string Message, bool WhenMissing = false);

public sealed record Finding(string RuleId, Severity Severity, string Message);

public static class JqlLinter
{
    private const RegexOptions Options = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant;

    public static readonly IReadOnlyList<LintRule> Rules =
    [
        new("JQL001", Severity.Warn, new(@"""epic link""", Options),
            "\"Epic Link\" was retired in February 2024; use parent = KEY"),
        new("JQL002", Severity.Warn, new(@"!?=\s*(openSprints|closedSprints|futureSprints)\(\)", Options),
            "sprint functions only support IN / NOT IN"),
        new("JQL003", Severity.Warn, new(@"!?=\s*membersOf\(", Options),
            "membersOf() only supports IN / NOT IN"),
        new("JQL004", Severity.Warn, new(@"[<>=]\s*(?<value>-\d+[wdhm])\b", Options),
            "relative date {0} without quotes; write \"{0}\""),
        new("JQL005", Severity.Info, new(@"\bstatus\s*(=|in)\s*\(?\s*""?done""?", Options),
            "status name Done is workflow-specific; statusCategory = Done covers every finished status"),
        new("JQL006", Severity.Info, new(@"currentUser\(\)", Options),
            "currentUser() needs a logged-in user; do not share this filter anonymously"),
        new("JQL007", Severity.Info, new(@"\border\s+by\b", Options),
            "no ORDER BY; say which order the readers of this filter expect", WhenMissing: true),
    ];

    public static IReadOnlyList<Finding> Check(string jql)
    {
        List<Finding> findings = [];
        foreach (LintRule rule in Rules)
        {
            Match match = rule.Pattern.Match(jql);
            // WhenMissing rules report when there is NO match (ORDER BY, for example)
            if (match.Success == rule.WhenMissing)
                continue;
            string value = match.Groups["value"].Value;
            findings.Add(new Finding(rule.Id, rule.Severity, string.Format(rule.Message, value)));
        }
        return findings;
    }
}

The part that reads the file and the command line are short too. If there are warnings the exit code is 1, so the same command can also run as a CI step.

namespace Bookstore.JiraTools;

public static class FilterFile
{
    // Format: one filter per line, "name: JQL". Lines starting with # are comments.
    public static IEnumerable<(string Name, string Jql)> Read(string path) =>
        File.ReadLines(path)
            .Where(line => line.Trim() is { Length: > 0 } trimmed && !trimmed.StartsWith('#'))
            .Select(line => line.Split(':', 2))
            .Where(parts => parts.Length == 2)
            .Select(parts => (parts[0].Trim(), parts[1].Trim()));
}
using Bookstore.JiraTools;

return args switch
{
    ["lint-jql", var path] => LintFilters(path),
    _ => Usage(),
};

static int LintFilters(string path)
{
    int filters = 0, warnings = 0, notes = 0;
    foreach (var (name, jql) in FilterFile.Read(path))
    {
        filters++;
        IReadOnlyList<Finding> findings = JqlLinter.Check(jql);
        Console.WriteLine(findings.Count == 0 ? $"{name}: ok" : name);
        foreach (Finding f in findings)
        {
            Console.WriteLine($"  {f.Severity.ToString().ToLowerInvariant(),-4}  {f.RuleId}  {f.Message}");
            if (f.Severity == Severity.Warn) warnings++; else notes++;
        }
    }
    Console.WriteLine($"{filters} filters, {warnings} warnings, {notes} notes");
    return warnings > 0 ? 1 : 0;
}

static int Usage()
{
    Console.Error.WriteLine("usage: jiratools lint-jql <filters.txt>");
    return 2;
}

Run from the root of the repo, the real output is this (exit code 1):

dotnet run --project tools/Bookstore.JiraTools -- lint-jql jira/filters.txt
my-stale-work
  info  JQL006  currentUser() needs a logged-in user; do not share this filter anonymously
bugs-without-epic
  warn  JQL001  "Epic Link" was retired in February 2024; use parent = KEY
  info  JQL007  no ORDER BY; say which order the readers of this filter expect
done-this-sprint
  warn  JQL002  sprint functions only support IN / NOT IN
  info  JQL005  status name Done is workflow-specific; statusCategory = Done covers every finished status
  info  JQL007  no ORDER BY; say which order the readers of this filter expect
review-queue
  warn  JQL004  relative date -2d without quotes; write "-2d"
4 filters, 3 warnings, 4 notes

Here are the answers to the first two questions. Emre's filter (JQL002) uses an operator the documentation does not support; the fix is sprint in openSprints(). The status = Done on the same line is not an error, but it only finds the status named exactly Done; once another end status such as Won't Do is added, the filter misses it. statusCategory = Done covers every end status. Ayşe's date (JQL004) should be in quotes. For bugs-without-epic, as we saw above, the documentation describes no one-line replacement; the warning stays and remains a decision for the team to discuss.

Know the limit of this tool: a check built on regular expressions does not really parse JQL. If "epic link" appears inside quoted text it raises a false alarm, and it does not see an error that is not in its rules. The real validation is still Jira's JQL editor; this tool only stops mistakes the team already knows about from coming back.

Saved Filters, Sharing and Dashboards

A saved JQL query becomes a filter. You can share it, star it, get its results by e-mail on a schedule, and use it as the source of a report or a dashboard gadget. Company-managed boards are themselves based on a filter; changing the board filter changes everything the board shows.

Sharing has two separate permissions: viewers can use the filter, editors can change it. The options are Private (only the owner and Jira admins), a user, group, space or space role, and My organization (every logged-in user on the site). The documentation also notes that every shared filter is visible to people with the Administer Jira permission and that changing ownership needs a Jira admin. E-mail subscriptions send only the first 200 results. If you use one filter inside another, refer to it by ID, not by name; the documentation's reason is that the name can change and the dependent filter breaks silently.

Any Jira user can create a dashboard; sharing is again set with viewers and editors. Gadgets take a space or a saved filter as their source and need the right fields: according to the documentation's example, the Workload pie chart reads time fields such as original estimate and time spent, not story points. A gadget based on a filter that contains currentUser() shows everyone who opens the dashboard their own work; that is the simplest way to build a shared "my work" board. A change to the default dashboard, on the other hand, changes the screen of everyone who uses it.

This section is based on the official documentation.

Which Question Does Each Jira Report Answer?

Jira's Scrum and Kanban reports belong to a board: they only count items that match the board filter, and they decide whether an item is To Do or Done from the column mapping. That is why the column and resolution decisions from post 02 show up directly in the reports.

ReportQuestion it answersData it relies on and what to watch
Velocity chartHow much work does the team finish in a sprint on average?The grey bar is the total estimate at sprint start (later additions excluded), the green bar the work finished at sprint end; the right-most column counts as Done
Burndown chartHow likely is the team to reach the sprint goal?Remaining estimate; story points on subtasks are not included; Scrum boards only
Cumulative flow diagramIn which column is work piling up?Each coloured area is a column; an area widening vertically is usually a bottleneck
Control chartHow long does an item stay in the chosen statuses?Cycle time or lead time, average, rolling average and standard deviation; company-managed spaces only
Created vs ResolvedIs more work coming in than being resolved?Relies entirely on the resolution field

We discussed what these reports show and do not show in detail in the eighth post of the Scrum series (velocity and burndown) and the fourth post of the Kanban series (cycle time, throughput, work item age). The only thing to add here is Jira-specific: on the control chart you choose between which statuses cycle time is measured, and the documentation says that if an item is reopened and worked on again, that extra time is added to its cycle time too. On the velocity chart the grey bar does not include work added after the sprint started; for a team that often adds work mid-sprint, comparing the grey and green bars can mislead.

This section is based on the official documentation.

In Production

The board filter is the foundation of everything; when you write a board filter that spans several spaces, keep the 5,000-item limit and performance in mind and narrow the filter by space and, if needed, by date. membersOf() can hit the 10,000-user limit with large umbrella groups; keep team groups small. And if a filter is used by several boards, gadgets and subscriptions, changing it changes all of them at once. That is where keeping filters in the repo really pays off for the Bookstore team: who changed what, and why, sits in the PR history.

Common Pitfalls

Selin's "Team Performance" dashboard is the answer to the third question: there is nothing wrong with the JQL, but putting the number of items each person resolved side by side teaches the team nothing; it only changes behaviour. People start splitting big work items, postponing reviews and closing their own items instead of helping someone else. Every metric in the Scrum and Kanban series makes sense at team level; a dashboard should ask questions about the team's flow, not about individuals.

The second pitfall is charts that look right but are wrong because of a wrong resolution or column mapping: Won't Do in the Done column inflates velocity, and an empty resolution flattens Created vs Resolved into a line. The third is filters written with status = Done that quietly return incomplete results once a new end status is added. The fourth is sharing a filter that contains sensitive information with My organization without thinking; even a filter's name and clauses, such as the name of an institutional customer, can reveal something not everyone should see.

A Small Experiment

★☆☆ Take the three filters your team uses most and write the one-sentence question for each. Is there a filter whose question cannot be written, or one that asks the same question as another?

★★☆ Add rule JQL008 to Bookstore.JiraTools: according to the documentation currentUser() only works with = and !=, so the rule should catch a use such as assignee in (currentUser()). Add a line with this mistake to filters.txt and look at the output and the exit code.

★★★ Redesign Selin's dashboard: choose three gadgets that show only the team's flow without naming anyone, and write down which filter feeds each, which question it answers and which column or resolution decision it relies on.

Now we can ask the right questions; the next step is handing repetitive work to Jira. In the next and final post of the series we look at the anatomy of automation rules, smart values, Atlassian's usage-based billing that starts on 3 December 2026, the GitHub integration and a small C# tool that drafts release notes through the REST API.